Compare commits

..

No commits in common. "master" and "v0.1.4" have entirely different histories.

33 changed files with 159 additions and 25851 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
node_modules
/dist
package-lock.json
# local env files
.env.local
.env.*.local

View file

@ -5,143 +5,6 @@ All notable changes to 'byjove' will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## Unreleased
## [0.1.26] - 2024-06-03
### Changed
- Optionally allow decimal quantities for receiving.
## [0.1.25] - 2024-05-29
### Changed
- Show actual error text if applicable, for receiving failure.
## [0.1.24] - 2024-03-26
### Changed
- Add delay when fetching rows data for model CRUD component.
## [0.1.23] - 2023-12-26
### Changed
- Improve focus behavior for inventory count view.
- Keep row filters "raw" until encoding for actual request.
## [0.1.22] - 2023-10-23
### Changed
- Use columns instead of table, for row receiving quantities.
- Show invoice number when receiving row.
- Center receiving buttons; add "12 EA" quick receive button.
## [0.1.21] - 2023-10-06
### Changed
- Auto-trim username field in login form.
- Auto-focus case/unit field when showing inventory count page.
## [0.1.20] - 2023-09-17
### Changed
- Validate amount when adding receiving quantity.
- Show "missing" quantities for item receiving.
## [0.1.19] - 2023-08-29
### Changed
- Add support for "missing" credit in mobile receiving.
## [0.1.18] - 2023-08-08
### Changed
- Add UPC quick lookup for View Product page.
- Add slot for "default panels" in View Product.
## [0.1.17] - 2023-08-03
### Changed
- Update to use lts/gallium version for nodejs.
## [0.1.16] - 2023-01-30
### Changed
- Add Prices panel for default product view.
## [0.1.15] - 2023-01-07
### Changed
- Add basic components for Product CRUD.
- Update dependencies.
- Center logo and text for home page.
## [0.1.14] - 2022-11-16
### Changed
- Detect 404 notfound when viewing CRUD record; warn user accordingly.
- Increase spacing around "Create" button for model-index.
- Put available settings in the store; etc.
- Add components for login form and customer field.
- Rename methods provided by plugin.
- Add home page component; fix user state issues.
- Allow static data set for autocomplete component.
- Add styles, warnings template for receiving component.
- Let item-level receiving default option reflect "what's left".
## [0.1.13] - 2021-10-20
### Changed
- Allow for "native sort" params in model index.
- Allow override of "create" permission for model index.
## [0.1.12] - 2021-08-04
### Changed
- Use better class name for logo image styles.
- Allow multiple feedback recipient options.
## [0.1.11] - 2021-04-12
### Changed
- Allow "any" number for inventory cases/units, including decimal.
## [0.1.10] - 2021-02-10
### Changed
- Add `package-lock.json` back to src repo.
- Add "angle-down" icon for user menu button.
## [0.1.9] - 2020-04-24
### Changed
- Wrap "create new object" button, for padding.
- Add `byjoveCurrency` filter.
- Fix pagination bug in model-crud.
- Add new `ByjoveReceiving` component.
- Let caller define row sorting for model-crud.
- Stop showing Buefy toast messages at bottom of screen.
- Misc. improvements for "delete" support in model-crud.
- Add support for "Create Row" button when viewing parent CRUD object.
- Avoid showing "removed" batch rows by default.
- Misc. fixes for model-crud, e.g. redirect to parent after deleting row.
- Add initial "inventory" component, for updating row counts.
## [0.1.8] - 2020-03-02
### Changed
- Allow customization of Save button for model-crud.
- Add basic support for "executing" mode, in model-crud.
- Fix the "view object" link in CRUD header.
- Add basic "quick-delete" prop for model-crud.
- Let caller define placeholder and icon for autocomplete field.
## [0.1.7] - 2020-02-26
### Changed
- Consolidate some perm check logic.
- Make sure we never ask for page 0 when fetching model index data.
- Tweak how/when Edit and Delete buttons are shown for model-crud.
## [0.1.6] - 2020-02-21
### Changed
- Add page-content-wrapper to main app template.
- Add icons to nav header buttons.
- Let caller control whether Login and Feedback menu items are shown.
- Let caller control whether app footer is shown.
- Stop including Home link in menu.
- Remove some redirect magic, for logged-in users.
- Add `session_established` flag in global store.
- Delay showing the app until user info is fetched.
- Add `ByjovePlugin` for sake of `$hasPerm()` method.
- Add `user_is_admin` flag to app store, plus logic to set/clear it.
- Change aria-role (to "menu") for main user menu.
- Add proper logout logic to menu; let caller hide root options, about link.
- Update permissions when logging in user.
## [0.1.5] - 2019-12-04
### Changed
- Fix bug when checking user session.
## [0.1.4] - 2019-12-02
### Added
- Add app/menu support for "become / stop being root" feature.

23339
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "byjove",
"version": "0.1.26",
"version": "0.1.4",
"description": "Generic-ish app components for Vue.js frontend to Tailbone API backend",
"keywords": [
"rattail",
@ -14,22 +14,21 @@
},
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build --target lib --name byjove src/index.js",
"build-watch": "vue-cli-service build --target lib --name byjove --watch src/index.js"
"build": "vue-cli-service build --target lib --name byjove src/index.js"
},
"dependencies": {
"vue": "^2.7.14"
"vue": "^2.6.10"
},
"devDependencies": {
"@vue/cli-service": "^3.0.5",
"rollup": "^1.32.1",
"rollup": "^1.26.3",
"rollup-plugin-buble": "^0.19.8",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-vue": "^5.1.9",
"vue-resource": "^1.5.3",
"vue-router": "^3.6.5",
"vue-template-compiler": "^2.7.14",
"vuex": "^3.6.2"
"rollup-plugin-vue": "^5.1.2",
"vue-resource": "^1.5.1",
"vue-router": "^3.1.3",
"vue-template-compiler": "^2.6.10",
"vuex": "^3.1.1"
},
"main": "./dist/byjove.umd.js",
"files": [

View file

@ -1,16 +1,12 @@
<template>
<!-- note, we do not show anything at all until we have user info -->
<div class="main-container" v-show="$store.state.session_established">
<div class="main-container">
<slot>
<p>TODO: put your nav in the default slot!</p>
</slot>
<div class="page-content-wrapper">
<div class="page-content">
<router-view />
</div>
<div class="page-content">
<router-view />
</div>
<footer v-if="includeFooter"
class="footer">
<footer class="footer">
<slot name="footer">
<router-link to="/about">{{ appsettings.appTitle }} {{ appsettings.version }}</router-link>
</slot>
@ -23,10 +19,6 @@ export default {
name: 'ByjoveApp',
props: {
appsettings: Object,
includeFooter: {
type: Boolean,
default: true,
},
},
beforeCreate: function() {
@ -42,21 +34,22 @@ export default {
this.$http.get('/api/session').then(response => {
// let all of app know who the user is(n't)
this.$store.commit('SET_USER', response.data.user || {})
this.$store.commit('SET_USER_IS_ADMIN', response.data.user ? response.data.user.is_admin : false)
this.$store.commit('SET_USER_IS_ROOT', response.data.user ? response.data.user.is_root : false)
this.$store.commit('SET_USER', response.data.user)
this.$store.commit('SET_USER_IS_ROOT', response.data.user.is_root)
// also keep track of user's permissions
this.$store.commit('SET_PERMISSIONS', response.data.permissions)
// also keep track of backend app settings
this.$store.commit('SET_SETTINGS', response.data.settings || {})
// if user is anonymous, and requested logout page, send to login instead
if (!response.data.user && this.$route.name == 'logout') {
this.$router.push('/login')
// declare the session established
this.$store.commit('SET_SESSION_ESTABLISHED', true)
// if user is logged in, and requested login page, send to home instead
} else if (response.data.user && this.$route.name == 'login') {
this.$router.push('/')
}
// set background color, to whatever api said
// TODO: should this be the custom app's responsibility?
if (response.data.background_color) {
document.body.style.backgroundColor = response.data.background_color
}

View file

@ -4,16 +4,12 @@
:name="name"
v-show="!selected"
v-model="autocompleteValue"
:placeholder="placeholder"
field="label"
:icon="icon"
:data="autocompleteData"
:data="data"
@typing="getAsyncData"
@select="selectionMade"
:open-on-focus="openOnFocus"
keep-first>
<template #header>
<slot name="header"></slot>
<template slot-scope="props">
{{ props.option.label }}
</template>
</b-autocomplete>
@ -30,34 +26,10 @@ export default {
name: 'ByjoveAutocomplete',
props: {
name: String,
serviceUrl: {
type: String,
default: null,
},
data: {
type: Array,
default: null,
},
dataFilter: {
type: Function,
default: null,
},
serviceUrl: String,
value: String,
initialLabel: String,
placeholder: {
type: String,
default: null,
},
icon: {
type: String,
default: null,
},
openOnFocus: {
type: Boolean,
default: false,
},
},
data() {
let selected = null
if (this.value) {
@ -67,30 +39,12 @@ export default {
}
}
return {
fetchedData: [],
data: [],
selected: selected,
isFetching: false,
autocompleteValue: this.value,
}
},
computed: {
autocompleteData() {
return this.serviceUrl ? this.fetchedData : this.filteredData
},
filteredData() {
if (this.dataFilter) {
return this.data.filter((option) => {
return this.dataFilter(this.autocompleteValue, option)
})
} else {
return this.data
}
},
},
methods: {
clearSelection() {
@ -102,13 +56,6 @@ export default {
})
},
// TODO: i am not clear yet what the best pattern is for setting
// the selected option. this does not seem complete but did the
// trick for what i needed atm..?
setSelected(option) {
this.selected = option
},
selectionMade(option) {
this.selected = option
this.$emit('input', option ? option.value : null)
@ -117,22 +64,16 @@ export default {
// TODO: buefy example uses `debounce()` here and perhaps we should too?
// https://buefy.org/documentation/autocomplete
getAsyncData: function (entry) {
// we only fetch async data via URL if we have one!
if (!this.serviceUrl) {
return
}
if (entry.length < 3) {
this.fetchedData = []
this.data = []
return
}
this.isFetching = true
this.$http.get(this.serviceUrl, {params: {term: entry}}).then((response) => {
this.fetchedData = response.data
this.data = response.data
}).catch((error) => {
this.fetchedData = []
this.data = []
throw error
}).finally(() => {
this.isFetching = false

View file

@ -1,162 +0,0 @@
<template>
<b-field :label="label" expanded
:type="fieldType">
<byjove-autocomplete v-if="!readonly && !useDropdown"
v-model="customerUUID"
ref="autocomplete"
:service-url="autocompleteUrl"
@input="customerChanged">
</byjove-autocomplete>
<b-select v-if="!readonly && useDropdown"
v-model="customerUUID"
@input="customerChanged">
<option v-for="customer in customers"
:key="customer.uuid"
:value="customer.uuid">
{{ customer.name }}
</option>
</b-select>
<router-link v-if="readonly"
:to="`/customers/${value}`">
{{ readonlyDisplay || value }}
</router-link>
</b-field>
</template>
<script>
import ByjoveAutocomplete from '../autocomplete'
export default {
name: 'ByjoveCustomerField',
components: {
ByjoveAutocomplete,
},
props: {
name: {
type: String,
},
label: {
type: String,
default: "Customer",
},
value: {
type: String,
},
readonly: {
type: Boolean,
default: false,
},
readonlyDisplay: {
type: String,
},
required: {
type: Boolean,
default: false,
},
dropdown: {
type: Boolean,
default: null,
},
autocompleteUrl: {
type: String,
// TODO: should not hard-code the "full" api endpoint url here
default: '/api/customers/autocomplete',
},
},
data() {
return {
inited: false,
customerUUID: null,
customers: [],
}
},
computed: {
fieldType() {
if (this.required && !this.customerUUID) {
return 'is-danger'
}
},
useDropdown() {
// use whatever caller specified, if they did so
if (this.dropdown !== null) {
return this.dropdown
}
// use setting, provided session is loaded
if (this.$store.state.session_established) {
return this.$store.state.settings['customer_field_dropdown']
}
// otherwise assume false by default
return false
},
},
created() {
this.init()
},
watch: {
'$store.state.session_established': 'init',
},
methods: {
init() {
// only need to init once
if (this.inited) {
return
}
// cannot init until session is established
if (!this.$store.state.session_established) {
return
}
this.inited = true
// fetch any needed data
this.fetchData()
},
fetchData() {
// nothing to fetch if not using dropdown
if (!this.useDropdown) {
return
}
// get all customers for dropdown
this.$http.get('/api/customers?sort=name|asc').then(response => {
this.customers = response.data.customers
})
},
customerChanged(value) {
this.$emit('input', value)
},
setCustomer(customer) {
this.$refs.autocomplete.selectionMade({
value: customer.uuid,
label: customer._str,
})
},
},
}
</script>

View file

@ -1,27 +0,0 @@
import ByjoveCustomerField from './ByjoveCustomerField.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveCustomerField', ByjoveCustomerField);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveCustomerField

View file

@ -1,7 +1,6 @@
<template>
<div>
<b-button type="is-primary"
icon-left="fas fa-comment"
@click="showFeedback()">
Feedback
</b-button>
@ -12,35 +11,16 @@
<section class="modal-card-body">
<p class="modal-card-title">User Feedback</p>
<br />
<p>
Questions, suggestions, comments, complaints, etc. regarding this
website are welcome and may be submitted below.
</p>
<div v-if="hasRecipientOptions">
<p class="block">
Comments welcome!&nbsp; Just let us know who they should go to.
</p>
<b-field label="Who To Notify?"
:type="recipient ? null : 'is-danger'">
<b-select v-model="recipient">
<option v-for="option in allRecipientOptions"
:key="option.value"
:value="option.value">
{{ option.label }}
</option>
</b-select>
</b-field>
</div>
<div v-if="!hasRecipientOptions">
<p>
Questions, suggestions, comments, complaints, etc. regarding this
website are welcome and may be submitted below.
</p>
<b-field label="Referring URL">
<div class="control">
{{ referringURL }}
</div>
</b-field>
</div>
<b-field label="Referring URL">
<div class="control">
{{ referringURL }}
</div>
</b-field>
<b-field label="Your Name"
v-show="!userUUID">
@ -55,12 +35,12 @@
</b-field>
<div class="buttons">
<b-button @click="cancelFeedback()">
<b-button @click="showFeedbackDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
@click="sendFeedback()"
:disabled="sendIsDisabled">
@click="sendFeedback()"
:disabled="!message">
Send Note
</b-button>
</div>
@ -78,64 +58,29 @@ export default {
url: String,
// userUUID: String,
// user: Object,
recipientOptions: {
type: Array,
default: null,
},
},
data() {
return {
showFeedbackDialog: false,
referringURL: null,
recipient: null,
userUUID: null,
userName: null,
message: null,
}
},
computed: {
allRecipientOptions() {
if (this.recipientOptions !== null) {
return this.recipientOptions
}
return []
},
hasRecipientOptions() {
return !!this.allRecipientOptions.length
},
sendIsDisabled() {
if (this.hasRecipientOptions && !this.recipient) {
return true
}
if (!this.message) {
return true
}
return false
},
},
methods: {
clearForm() {
this.recipient = null
this.message = null
},
showFeedback() {
this.referringURL = location.href
if (this.$store.state.user) {
this.userUUID = this.$store.state.user.uuid
}
this.message = null
this.showFeedbackDialog = true
},
cancelFeedback() {
this.showFeedbackDialog = false
// this.clearForm()
},
sendFeedback() {
let params = {
email_key: this.recipient,
referrer: this.referringURL,
user: this.userUUID,
// user: this.user ? this.user.uuid : null,
@ -144,15 +89,16 @@ export default {
}
this.$http.post(this.url, params).then(response => {
this.showFeedbackDialog = false
this.clearForm()
this.$buefy.toast.open({
message: "Thank you for your feedback!",
type: 'is-success',
position: 'is-bottom',
})
}, response => {
this.$buefy.toast.open({
message: "Something went wrong!",
type: 'is-danger',
position: 'is-bottom',
})
})
},

View file

@ -1,55 +0,0 @@
<template>
<div class="has-text-centered">
<img :alt="`${appsettings.systemTitle} logo`" :src="appsettings.logo" />
<h1>Welcome to {{ appsettings.appTitle }}</h1>
</div>
</template>
<script>
export default {
name: 'ByjoveHome',
props: {
appsettings: {
type: Object,
},
forceLogin: {
type: Boolean,
default: false,
},
},
data() {
return {
inited: false,
}
},
created() {
this.init()
},
watch: {
'$store.state.session_established': 'init',
},
methods: {
init() {
if (this.inited) return
// nothing to check, unless login is to be enforced
if (!this.forceLogin) {
this.inited = true
return
}
// cannot init until session is established
if (!this.$store.state.session_established) {
return
}
this.inited = true
// send anonymous users to login page
if (!this.$store.state.user.uuid) {
this.$router.push('/login')
}
},
},
}
</script>

View file

@ -1,28 +0,0 @@
// Import vue component
import ByjoveHome from './ByjoveHome.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveHome', ByjoveHome);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveHome

View file

@ -1,32 +1,17 @@
import ByjoveApp from './app'
import ByjoveMenu from './menu'
import ByjoveHome from './home'
import ByjoveLogo from './logo'
import ByjoveFeedback from './feedback'
import ByjoveLogin from './login'
import ByjoveAutocomplete from './autocomplete'
import ByjoveModelIndex from './model-index'
import ByjoveModelCrud from './model-crud'
import ByjoveScannerInput from './scanner-input'
import {ByjoveProducts, ByjoveProduct} from './products'
import ByjoveCustomerField from './customers'
import ByjoveInventory from './inventory'
import ByjoveReceiving from './receiving'
export {
ByjoveApp,
ByjoveMenu,
ByjoveHome,
ByjoveLogo,
ByjoveFeedback,
ByjoveLogin,
ByjoveAutocomplete,
ByjoveModelIndex,
ByjoveModelCrud,
ByjoveScannerInput,
ByjoveProducts,
ByjoveProduct,
ByjoveCustomerField,
ByjoveInventory,
ByjoveReceiving,
}

View file

@ -1,217 +0,0 @@
<template>
<div class="inventory">
<nav class="breadcrumb">
<ul>
<li>
<router-link :to="getIndexUrl()">
Inventory
</router-link>
</li>
<li>
<router-link :to="getBatchUrl()">
{{ row.batch_id_str }}
</router-link>
</li>
<li>
&nbsp; {{ row[productKey] }}
</li>
</ul>
</nav>
<div style="display: flex;">
<div style="flex-grow: 1;">
<p class="has-text-weight-bold">
{{ row.brand_name }}
</p>
<p class="has-text-weight-bold">
{{ row.description }} {{ row.size }}
</p>
<p class="has-text-weight-bold">
<br />
1 CS = {{ row.case_quantity || 1 }} {{ row.unit_uom }}
</p>
</div>
<div>
<img :src="row.image_url" />
</div>
</div>
<b-field grouped>
<b-field label="Cases" expanded>
<b-input v-model="row.cases"
type="number"
step="any"
ref="cases"
:disabled="!shouldAllowCases()">
</b-input>
</b-field>
<b-field label="Units" expanded>
<b-input v-model="row.units"
type="number"
step="any"
ref="units">
</b-input>
</b-field>
</b-field>
<div class="buttons">
<b-button type="is-primary"
icon-left="save"
@click="saveCounts()">
Save Counts
</b-button>
<b-button tag="router-link"
:to="`/inventory/rows/${row.uuid}`">
Cancel
</b-button>
</div>
</div>
</template>
<script>
export default {
name: 'ByjoveInventory',
props: {
productKey: {
type: String,
default: 'upc_pretty',
},
allowCases: {
type: Boolean,
default: true,
},
focusCases: {
type: Boolean,
default: false,
},
allowEdit: {
type: Boolean,
default: false,
},
allowDelete: {
type: Boolean,
default: false,
},
},
data() {
return {
row: {},
}
},
created() {
this.fetch(this.$route.params.uuid)
},
methods: {
getIndexUrl() {
return '/inventory/'
},
getBatchUrl() {
return `/inventory/${this.row.batch_uuid}`
},
getViewUrl() {
return `/inventory/rows/${this.row.uuid}`
},
getDeleteUrl() {
return `/inventory/rows/${this.row.uuid}/delete`
},
getApiUrl(uuid) {
if (!uuid) {
uuid = this.row.uuid
}
return `/api/inventory-batch-row/${uuid}`
},
shouldAllowCases() {
if (!this.allowCases) {
return false
}
return this.row.allow_cases
},
shouldAllowEdit() {
if (!this.allowEdit) {
return false
}
return true
},
shouldAllowDelete() {
if (!this.allowDelete) {
return false
}
return true
},
fetch(uuid) {
let url = this.getApiUrl(uuid)
this.$http.get(url).then(response => {
this.row = response.data.data
if (this.row.batch_executed || this.row.batch_complete) {
// cannot edit this row, so view it instead
this.$router.push(this.getViewUrl())
} else {
this.$nextTick(() => {
if (this.shouldAllowCases() && this.focusCases) {
this.$refs.cases.focus()
} else {
this.$refs.units.focus()
}
})
}
}, response => {
if (response.status == 403) { // forbidden; redirect to model index
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
})
this.$router.push(this.getIndexUrl())
} else {
this.$buefy.toast.open({
message: "Failed to fetch page data!",
type: 'is-danger',
})
}
})
},
saveCounts() {
let url = this.getApiUrl()
let params = {
row: this.row.uuid,
units: this.row.units,
}
if (this.shouldAllowCases()) {
params.cases = this.row.cases
}
this.$http.post(url, params).then(response => {
if (response.data.data) {
// return to batch view
this.$router.push(this.getBatchUrl())
} else {
this.$buefy.toast.open({
message: response.data.error || "Failed to save counts!",
type: 'is-danger',
})
}
}, response => {
this.$buefy.toast.open({
message: "Failed to save counts (server error)!",
type: 'is-danger',
})
})
},
},
}
</script>

View file

@ -1,28 +0,0 @@
// Import vue component
import ByjoveInventory from './ByjoveInventory.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveInventory', ByjoveInventory);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveInventory

View file

@ -1,117 +0,0 @@
<template>
<div class="byjove-login">
<form>
<byjove-logo centered :appsettings="appsettings">
</byjove-logo>
<b-field label="Username">
<b-input v-model.trim="username" />
</b-field>
<b-field label="Password">
<b-input v-model="password" type="password" />
</b-field>
<div v-if="loginError" style="color: red;">
{{ loginError }}
</div>
<div class="buttons">
<b-button type="is-primary"
@click="attemptLogin()"
:disabled="loginFormDisabled"
expanded>
Login
</b-button>
</div>
</form>
</div>
</template>
<script>
import ByjoveLogo from '../logo'
export default {
name: 'ByjoveLogin',
components: {
ByjoveLogo,
},
props: {
appsettings: Object,
},
data() {
return {
inited: false,
username: null,
password: null,
loginError: null,
}
},
computed: {
loginFormDisabled: function() {
if (!this.username) {
return true
} else if (!this.password) {
return true
}
return false
},
},
created() {
this.init()
},
watch: {
'$store.state.session_established': 'init',
},
methods: {
init() {
if (this.inited) return
// cannot init until session is established
if (!this.$store.state.session_established) {
return
}
this.inited = true
// send logged-in users to "home" page
if (this.$store.state.user.uuid) {
this.$router.push('/')
}
},
attemptLogin() {
// clear any previous login error
this.loginError = null
// post credentials to login API
let creds = {
username: this.username,
password: this.password,
}
this.$http.post('/api/login', creds).then(response => {
if (response.data.ok) {
// login successful; let the app know
this.$byjoveLoginUser(response.data.user,
response.data.permissions)
// send user to "home" page
this.$router.push('/')
} else {
// login failed
this.loginError = response.data.error;
}
})
},
},
}
</script>

View file

@ -1,27 +0,0 @@
import ByjoveLogin from './ByjoveLogin.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveLogin', ByjoveLogin);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveLogin

View file

@ -1,6 +1,5 @@
<template>
<div class="byjove-logo"
:style="centered ? 'text-align: center;' : null">
<div class="logo">
<img v-if="appsettings.logo" :alt="alternateText" :src="appsettings.logo" />
<img v-if="!appsettings.logo" :alt="alternateText" src="../../assets/logo.png" />
</div>
@ -11,7 +10,6 @@ export default {
name: 'ByjoveLogo',
props: {
appsettings: Object,
centered: Boolean,
},
computed: {
alternateText: function() {
@ -22,7 +20,7 @@ export default {
</script>
<style scoped>
.byjove-logo img {
img {
max-height: 200px;
max-width: 300px;
}

View file

@ -1,68 +1,60 @@
<template>
<section>
<nav class="level is-mobile">
<div class="level-item">
<div v-if="user.uuid"
class="level-item">
<b-dropdown v-if="user"
aria-role="list">
<button class="button is-primary"
:class="{'is-danger': user_is_root}"
slot="trigger">
<span>{{ user.short_name }}</span>
<!-- <b-icon icon="menu-down"></b-icon> -->
</button>
<b-dropdown aria-role="menu">
<b-button type="is-primary"
:class="{'is-danger': exposeRoot && user_is_root}"
slot="trigger"
icon-left="user"
icon-right="angle-down">
{{ user.short_name }}
</b-button>
<b-dropdown-item aria-role="listitem" has-link>
<router-link to="/">Home</router-link>
</b-dropdown-item>
<slot></slot>
<b-dropdown-item v-if="exposeRoot && $store.state.user_is_admin && !user_is_root"
aria-role="menuitem"
<b-dropdown-item v-if="user.is_admin && !user_is_root"
aria-role="listitem"
has-link
class="root-user">
<a href="#" @click.prevent="becomeRoot()">Become root</a>
</b-dropdown-item>
<b-dropdown-item v-if="exposeRoot && user_is_root"
aria-role="menuitem"
<b-dropdown-item v-if="user_is_root"
aria-role="listitem"
has-link
class="root-user">
<a href="#" @click.prevent="stopRoot()">Stop being root</a>
</b-dropdown-item>
<b-dropdown-item aria-role="menuitem" has-link>
<a href="#" @click.prevent="logout()">Logout</a>
<b-dropdown-item aria-role="listitem" has-link>
<router-link to="/logout">Logout</router-link>
</b-dropdown-item>
<b-dropdown-item v-if="includeAboutLink"
aria-role="menuitem" has-link>
<b-dropdown-item aria-role="listitem" has-link>
<router-link to="/about">About</router-link>
</b-dropdown-item>
</b-dropdown>
</div>
<div v-if="!user.uuid && showLoginButton"
class="level-item">
<b-button type="is-primary"
<b-button v-if="!user"
type="is-primary"
tag="router-link" to="/login">
<b-icon icon="fas fa-user"></b-icon>
<span>Login</span>
Login
</b-button>
</div>
<div class="level-item">
<slot name="header">
{{ title }}
</slot>
{{ title }}
</div>
<div v-if="shouldShowFeedback"
class="level-item">
<byjove-feedback :url="feedbackUrl"
:recipient-options="feedbackRecipientOptions">
</byjove-feedback>
<div class="level-item">
<byjove-feedback :url="feedbackUrl"></byjove-feedback>
</div>
</nav>
@ -75,38 +67,10 @@ export default {
name: 'ByjoveMenu',
props: {
appsettings: Object,
showLoginButton: {
type: Boolean,
default: true,
},
logoutUrl: {
type: String,
default: '/api/logout',
},
allowFeedback: {
type: Boolean,
default: null,
},
allowAnonymousFeedback: {
type: Boolean,
default: false,
},
feedbackUrl: {
type: String,
default: '/api/feedback',
},
feedbackRecipientOptions: {
type: Array,
default: null,
},
includeAboutLink: {
type: Boolean,
default: true,
},
exposeRoot: {
type: Boolean,
default: true,
},
becomeRootUrl: {
type: String,
default: '/api/become-root',
@ -133,36 +97,10 @@ export default {
user_is_root: function() {
return this.$store.state.user_is_root
},
shouldShowFeedback: function() {
if (this.allowFeedback !== null) {
return this.allowFeedback
}
if (!this.allowAnonymousFeedback && !this.user.uuid) {
return false
}
return true
},
},
methods: {
logout() {
// submit user logout request to backend
this.$http.post(this.logoutUrl).then(response => {
// do logout proper, within our app
this.$logoutUser()
// re-fetch session to get fresh XSRF token cookie; our http
// interceptor will handle the rest of that for us. once we
// have token we show the login form again
this.$http.get('/api/session').then(response => {
this.$router.push('/login')
})
})
},
becomeRoot() {
if (this.user_is_root || !this.$store.state.user_is_admin) {
if (this.user_is_root || !this.user.is_admin) {
return
}
this.$http.post(this.becomeRootUrl).then(response => {
@ -170,11 +108,13 @@ export default {
this.$buefy.toast.open({
message: "You have been elevated to 'root' and now have full system access",
type: 'is-success',
position: 'is-bottom',
})
}, response => {
this.$buefy.toast.open({
message: "Something went wrong!",
type: 'is-danger',
position: 'is-bottom',
})
})
@ -188,11 +128,13 @@ export default {
this.$buefy.toast.open({
message: "Your normal system access has been restored",
type: 'is-success',
position: 'is-bottom',
})
}, response => {
this.$buefy.toast.open({
message: "Something went wrong!",
type: 'is-danger',
position: 'is-bottom',
})
})
},

View file

@ -6,7 +6,7 @@
<router-link :to="getModelPathPrefix() + '/'">{{ getModelIndexTitle() }}</router-link>
</li>
<li v-if="isRow">
<router-link :to="getParentUrl()">{{ renderParentHeaderLabel(record) }}</router-link>
<router-link :to="getModelPathPrefix() + '/' + record._parent_uuid">{{ renderParentHeaderLabel(record) }}</router-link>
<!-- &nbsp; {{ renderParentHeaderLabel(record) }} -->
</li>
<li v-if="mode == 'creating'">
@ -15,17 +15,12 @@
<li v-if="mode == 'viewing'">
&nbsp; {{ renderHeaderLabel(record) }}
</li>
<li v-if="mode == 'editing' || mode == 'executing' || mode == 'deleting'">
<router-link :to="getViewURL()">
{{ renderHeaderLabel(record) }}
</router-link>
<li v-if="mode == 'editing' || mode == 'deleting'">
<router-link :to="getModelPathPrefix() + '/' + record.uuid">{{ renderHeaderLabel(record) }}</router-link>
</li>
<li v-if="mode == 'editing'">
&nbsp; Edit
</li>
<li v-if="mode == 'executing'">
&nbsp; Execute
</li>
<li v-if="mode == 'deleting'">
&nbsp; Delete
</li>
@ -34,75 +29,35 @@
<slot></slot>
<b-button v-if="allowEdit && mode == 'viewing' && hasModelPerm('edit')"
type="is-primary"
tag="router-link"
:to="getModelPathPrefix() + '/' + record.uuid + '/edit'">
Edit This
</b-button>
<div v-if="showButtons"
class="buttons" style="margin-top: 1rem;">
<b-button :type="getSaveButtonType()"
v-if="!hideSaveButton"
:icon-left="getSaveButtonIcon()"
class="buttons">
<b-button type="is-primary"
:disabled="saveDisabled"
@click="save()">
{{ getSaveButtonText() }}
Save
</b-button>
<b-button tag="router-link"
:to="getCancelURL()">
<b-button v-if="mode == 'creating'"
tag="router-link"
:to="getModelPathPrefix() + '/'">
Cancel
</b-button>
</div>
<div v-if="shouldAllowEdit() || shouldAllowDelete()" class="buttons">
<br /><br />
<b-button v-if="shouldAllowEdit()"
type="is-primary"
icon-left="edit"
<b-button v-if="mode == 'editing'"
tag="router-link"
:to="getEditURL()">
Edit this {{ getModelTitle() }}
</b-button>
<b-button v-if="shouldAllowDelete() && !quickDelete"
type="is-danger"
icon-left="trash"
tag="router-link"
:to="getDeleteURL()">
Delete this {{ getModelTitle() }}
</b-button>
<b-button v-if="shouldAllowDelete() && quickDelete"
type="is-danger"
icon-left="trash"
@click="$emit('delete')">
Delete this {{ getModelTitle() }}
:to="getModelPathPrefix() + '/' + record.uuid">
Cancel
</b-button>
</div>
<slot name="quick-entry"></slot>
<div v-if="hasRows && mode == 'viewing'">
<div v-if="rowsTitle && shouldAllowCreateRow()"
style="display: flex;">
<h2 style="flex-grow: 1;">{{ rowsTitle }}</h2>
<b-button type="is-primary"
tag="router-link"
:to="getCreateRowButtonUrl()"
icon-left="plus">
{{ createRowButtonText }}
</b-button>
</div>
<h2 v-if="rowsTitle && !shouldAllowCreateRow()">
{{ rowsTitle }}
</h2>
<b-button v-if="shouldAllowCreateRow() && !rowsTitle"
type="is-primary"
tag="router-link"
:to="getCreateRowButtonUrl()"
icon-left="plus">
{{ createRowButtonText }}
</b-button>
<slot name="row-filters"></slot>
<b-menu>
<b-menu-list>
@ -120,7 +75,9 @@
:total="rowData.total"
:current.sync="rowPage"
:per-page="rowsPerPage"
@change="changeRowPagination">
@change="changeRowPagination"
>
<!-- icon-pack="fas" -->
</b-pagination>
</div>
@ -151,26 +108,6 @@ export default {
type: Boolean,
default: false,
},
rowsTitle: {
type: String,
default: null,
},
allowCreateRow: {
type: Boolean,
default: false,
},
createRowPermission: {
type: String,
default: null,
},
createRowButtonText: {
type: String,
default: "Create New Row",
},
createRowButtonUrl: {
type: String,
default: null,
},
rowsPaginated: {
type: Boolean,
default: true,
@ -189,56 +126,21 @@ export default {
rowFilters: {
type: Function,
default: (uuid) => {
return [
{field: 'batch_uuid', op: 'eq', value: uuid},
{field: 'removed', op: 'eq', value: false},
]
return JSON.stringify([{field: 'batch_uuid', op: 'eq', value: uuid}])
},
},
rowOrderBy: {
type: String,
default: 'modified',
},
rowOrderAscending: {
type: Boolean,
default: false,
},
allowEdit: {
type: Boolean,
default: true,
},
allowDelete: {
type: Boolean,
default: false,
},
quickDelete: {
type: Boolean,
default: false,
},
hideButtons: {
type: Boolean,
default: false,
},
hideSaveButton: {
type: Boolean,
default: false,
},
saveDisabled: {
type: Boolean,
default: false,
},
saveButtonText: {
type: String,
default: null,
},
saveButtonIcon: {
type: String,
default: null,
},
cancelUrl: {
type: String,
default: null,
},
},
data: function() {
return {
@ -259,9 +161,6 @@ export default {
if (this.mode == 'editing') {
return true
}
if (this.mode == 'deleting') {
return true
}
return false
},
},
@ -274,6 +173,7 @@ export default {
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
position: 'is-bottom',
})
this.$router.push(this.getModelPathPrefix() + '/')
}
@ -308,6 +208,7 @@ export default {
// this.$buefy.toast.open({
// message: "You do not have permission to access that page.",
// type: 'is-danger',
// position: 'is-bottom',
// })
// this.$router.push(this.getModelPathPrefix() + '/')
// return
@ -321,11 +222,6 @@ export default {
methods: {
clear() {
this.record = {}
this.$emit('refresh', this.record)
},
getModelSlug() {
if (this.modelSlug) {
return this.modelSlug
@ -375,37 +271,6 @@ export default {
return '/' + this.getModelSlug() + '/rows'
},
getIndexURL() {
return `${this.getModelPathPrefix()}/`
},
getParentUrl() {
if (this.isRow) {
return `${this.getModelPathPrefix()}/${this.record._parent_uuid}`
}
},
getViewURL() {
if (this.isRow) {
return `${this.getRowPathPrefix()}/${this.record.uuid}`
}
return `${this.getModelPathPrefix()}/${this.record.uuid}`
},
getEditURL() {
if (this.isRow) {
return `${this.getRowPathPrefix()}/${this.record.uuid}/edit`
}
return `${this.getModelPathPrefix()}/${this.record.uuid}/edit`
},
getDeleteURL() {
if (this.isRow) {
return `${this.getRowPathPrefix()}/${this.record.uuid}/delete`
}
return `${this.getModelPathPrefix()}/${this.record.uuid}/delete`
},
getApiIndexUrl() {
if (this.apiIndexUrl) {
return this.apiIndexUrl
@ -413,7 +278,7 @@ export default {
return '/api/' + this.getModelSlug()
},
getApiObjectUrlBase() {
getApiObjectUrl() {
if (this.apiObjectUrl) {
return this.apiObjectUrl
}
@ -422,14 +287,6 @@ export default {
return url.slice(0, -1) + '/'
},
getApiObjectUrl(uuid) {
let url = this.getApiObjectUrlBase()
if (uuid) {
url += uuid
}
return url
},
getApiRowsUrl() {
return this.apiRowsUrl
},
@ -441,11 +298,22 @@ export default {
return this.getModelSlug()
},
hasPerm(perm) {
// if user is root then assume permission
if (this.$store.state.user && this.$store.state.user.is_root) {
return true
}
// otherwise do true perm check for user
return this.$store.state.permissions.includes(perm)
},
hasModelPerm(perm) {
// do normal check, but first add prefix
let prefix = this.getModelPermissionPrefix()
return this.$byjoveHasPerm(prefix + '.' + perm)
return this.hasPerm(prefix + '.' + perm)
},
renderLabel(obj) {
@ -476,78 +344,37 @@ export default {
return row._str
},
getSaveButtonIcon() {
if (this.saveButtonIcon) {
return this.saveButtonIcon
}
if (this.mode == 'deleting') {
return 'trash'
}
return 'save'
},
getSaveButtonText() {
if (this.saveButtonText) {
return this.saveButtonText
}
if (this.mode == 'deleting') {
return "Delete This Forever!"
}
if (this.mode == 'creating') {
return `Create ${this.getModelTitle()}`
}
return "Save Data"
},
getSaveButtonType() {
if (this.mode == 'deleting') {
return 'is-danger'
}
return 'is-primary'
},
fetch(uuid) {
this.$http.get(this.getApiObjectUrl(uuid)).then(response => {
this.$http.get(this.getApiObjectUrl() + uuid).then(response => {
this.record = response.data.data
this.$emit('refresh', this.record)
if (this.hasRows) {
// TODO: was seeing occasional errors when a batch
// view was loaded, and it tried to fetch rows but
// somehow the uuid was not passed along and
// server failed to build a query filter. so
// hoping the nextTick() delay fixes it..?
this.$nextTick(() => {
this.fetchRows(uuid)
})
this.fetchRows(uuid)
}
}, response => {
if (response.status == 403) { // forbidden
if (response.status == 403) { // forbidden; redirect to model index
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
position: 'is-bottom',
})
} else if (response.status == 404) { // notfound
this.$buefy.toast.open({
message: `The requested ${this.getModelTitle()} was not found.`,
type: 'is-danger',
})
} else { // other error
this.$router.push(this.getModelPathPrefix() + '/')
} else {
this.$buefy.toast.open({
message: "Failed to fetch page data!",
type: 'is-danger',
position: 'is-bottom',
})
}
// redirect to model index
this.$router.push(this.getIndexURL())
})
},
fetchRows(uuid) {
let params = {
filters: JSON.stringify(this.rowFilters(uuid)),
orderBy: this.rowOrderBy,
ascending: this.rowOrderAscending ? 1 : 0,
filters: this.rowFilters(uuid),
orderBy: 'modified',
ascending: 0,
}
if (this.rowsPaginated) {
params.per_page = this.rowsPerPage
@ -555,22 +382,20 @@ export default {
}
this.$http.get(this.getApiRowsUrl(), {params: params}).then(response => {
this.rowData = response.data
// TODO: for some reason rowPage is getting reset to 0, maybe by
// the b-pagination component? for now this is our workaround
if (!this.rowPage) {
this.rowPage = 1
}
}, response => {
if (response.status == 403) { // forbidden; redirect to home page
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
position: 'is-bottom',
})
this.$router.push('/')
} else {
this.$buefy.toast.open({
message: "Failed to fetch page data!",
type: 'is-danger',
position: 'is-bottom',
})
}
})
@ -587,111 +412,13 @@ export default {
this.fetchRows(this.record.uuid)
},
shouldAllowEdit() {
if (!this.allowEdit) {
return false
}
if (this.mode != 'viewing') {
return false
}
if (!this.hasModelPerm('edit')) {
return false
}
return true
},
shouldAllowDelete() {
if (!this.allowDelete) {
return false
}
if (this.mode != 'viewing') {
return false
}
if (!this.hasModelPerm('delete')) {
return false
}
return true
},
getCancelURL() {
if (this.cancelUrl) {
return this.cancelUrl
}
if (this.mode == 'creating') {
return this.getIndexURL()
}
return this.getViewURL()
},
save() {
if (this.mode == 'deleting') {
this.confirmDelete()
return
}
let url
if (this.mode == 'creating') {
url = this.getApiIndexUrl()
} else {
url = this.getApiObjectUrl(this.record.uuid)
let url = this.getApiIndexUrl()
if (this.mode != 'creating') {
url = this.getApiObjectUrl() + this.record.uuid
}
this.$emit('save', url)
},
confirmDelete() {
let modelTitle = this.getModelTitle()
this.$buefy.dialog.confirm({
title: `Delete ${modelTitle}`,
message: `Are you sure you wish to delete this ${modelTitle}?`,
hasIcon: true,
type: 'is-danger',
confirmText: `Delete ${modelTitle}`,
cancelText: "Whoops, nevermind",
onConfirm: this.deleteObject,
})
},
deleteObject() {
let url = this.getApiObjectUrl(this.record.uuid)
this.$http.delete(url).then(response => {
if (this.isRow) {
this.$router.push(this.getParentUrl())
} else {
this.$router.push(this.getIndexURL())
}
}, response => {
this.$buefy.toast.open({
message: "Failed to delete the record!",
type: 'is-danger',
})
})
},
shouldAllowCreateRow() {
if (!this.allowCreateRow) {
return false
}
if (this.mode != 'viewing') {
return false
}
if (this.createRowPermission) {
if (!this.$byjoveHasPerm(this.createRowPermission)) {
return false
}
} else {
if (!this.hasModelPerm('create_row')) {
return false
}
}
return true
},
getCreateRowButtonUrl() {
if (this.createRowButtonUrl) {
return this.createRowButtonUrl
}
return this.getApiRowsUrl()
},
},
}
</script>

View file

@ -7,16 +7,13 @@
</ul>
</nav>
<div class="buttons block"
v-if="allowCreate && userCanCreate()">
<b-button class="new-object"
type="is-primary"
tag="router-link"
:to="getModelPathPrefix() + '/new'"
icon-left="plus">
New {{ getModelTitle() }}
</b-button>
</div>
<b-button v-if="allowCreate && hasModelPerm('create')"
class="new-object"
type="is-primary"
tag="router-link"
:to="getModelPathPrefix() + '/new'">
New {{ getModelTitle() }}
</b-button>
<slot></slot>
@ -56,21 +53,12 @@ export default {
modelPathPrefix: String,
labelRenderer: Function,
apiIndexUrl: String,
apiIndexSort: [Array, Object],
apiIndexSort: Object,
apiIndexFilters: Array,
nativeSort: {
type: Boolean,
default: false,
},
wantInitialResults: {
type: Boolean,
default: true,
},
allowCreate: {
type: Boolean,
default: true,
},
createPermCheck: Function,
paginated: {
type: Boolean,
default: true,
@ -87,9 +75,7 @@ export default {
}
},
mounted() {
if (this.wantInitialResults) {
this.fetchData()
}
this.fetchData()
},
watch: {
'apiIndexFilters' (to, from) {
@ -105,16 +91,12 @@ export default {
fetchData() {
let params = {
filters: JSON.stringify(this.apiIndexFilters),
}
if (this.nativeSort) {
params.nativeSort = JSON.stringify(this.apiIndexSort)
} else {
params.orderBy = this.apiIndexSort.field
params.ascending = (this.apiIndexSort.dir == 'asc') ? 1 : 0
orderBy: this.apiIndexSort.field,
ascending: (this.apiIndexSort.dir == 'asc') ? 1 : 0,
}
if (this.paginated) {
params.per_page = this.perPage
params.page = this.page || 1
params.page = this.page
}
this.$http.get(this.getApiIndexUrl(), {params: params}).then(response => {
this.data = response.data
@ -124,17 +106,14 @@ export default {
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
position: 'is-bottom',
})
this.$router.push('/')
} else if (response.status == 404) { // not found; display warning
this.$buefy.toast.open({
message: "Page data not found!",
type: 'is-danger',
})
} else {
this.$buefy.toast.open({
message: "Failed to fetch page data!",
type: 'is-danger',
position: 'is-bottom',
})
}
})
@ -189,18 +168,22 @@ export default {
return this.getModelSlug()
},
userCanCreate() {
if (this.createPermCheck) {
return this.createPermCheck()
hasPerm(perm) {
// if user is root then assume permission
if (this.$store.state.user && this.$store.state.user.is_root) {
return true
}
return this.hasModelPerm('create')
// otherwise do true perm check for user
return this.$store.state.permissions.includes(perm)
},
hasModelPerm(perm) {
// do normal check, but first add prefix
let prefix = this.getModelPermissionPrefix()
return this.$hasPerm(prefix + '.' + perm)
return this.hasPerm(prefix + '.' + perm)
},
renderLabel(obj) {

View file

@ -1,152 +0,0 @@
<template>
<b-input :name="name"
:value="value"
ref="input"
:placeholder="placeholder"
:size="size"
:icon-pack="iconPack"
:icon="icon"
:disabled="disabled"
:custom-class="customClass"
@focus="notifyFocus"
@blur="notifyBlur"
@keydown.native="keyDown"
@input="valueChanged"
/>
</template>
<script>
export default {
name: 'NumericInput',
props: {
name: String,
value: [Number, String],
placeholder: String,
iconPack: String,
icon: String,
size: String,
disabled: Boolean,
allowEnter: Boolean,
customClass: String,
},
methods: {
focus() {
this.$refs.input.focus()
},
notifyFocus(event) {
this.$emit('focus', event)
},
notifyBlur(event) {
this.$emit('blur', event)
},
keyDown(event) {
// by default we only allow numeric keys, and general navigation
// keys, but we might also allow Enter key
if (!this.key_modifies(event) && !this.key_allowed(event)) {
if (!this.allowEnter || event.which != 13) {
event.preventDefault()
}
}
},
/*
* Determine if a keypress would modify the value of a textbox.
*
* Note that this implies that the keypress is also *valid* in the context of a
* numeric textbox.
*
* Returns `true` if the keypress is valid and would modify the textbox value,
* or `false` otherwise.
*/
key_modifies(event) {
if (event.which >= 48 && event.which <= 57) { // Numeric (QWERTY)
if (! event.shiftKey) { // shift key means punctuation instead of numeric
return true;
}
} else if (event.which >= 96 && event.which <= 105) { // Numeric (10-Key)
return true;
} else if (event.which == 109 || event.which == 173) { // hyphen (negative sign)
return true;
} else if (event.which == 110 || event.which == 190) { // period/decimal
return true;
} else if (event.which == 8) { // Backspace
return true;
} else if (event.which == 46) { // Delete
return true;
} else if (event.ctrlKey && event.which == 86) { // Ctrl+V
return true;
} else if (event.ctrlKey && event.which == 88) { // Ctrl+X
return true;
}
return false;
},
/*
* Determine if a keypress is allowed in the context of a textbox.
*
* The purpose of this function is to let certain "special" keys (e.g. function
* and navigational keys) to pass through, so they may be processed as they
* would for a normal textbox.
*
* Note that this function does *not* check for keys which would actually
* modify the value of the textbox. It is assumed that the caller will have
* already used `key_modifies()` for that.
*
* Returns `true` if the keypress is allowed, or `false` otherwise.
*/
key_allowed(event) {
// Allow anything with modifiers (except Shift).
if (event.altKey || event.ctrlKey || event.metaKey) {
// ...but don't allow Ctrl+X or Ctrl+V
return event.which != 86 && event.which != 88;
}
// Allow function keys.
if (event.which >= 112 && event.which <= 123) {
return true;
}
// Allow Home/End/arrow keys.
if (event.which >= 35 && event.which <= 40) {
return true;
}
// Allow Tab key.
if (event.which == 9) {
return true;
}
// allow Escape key
if (event.which == 27) {
return true;
}
return false;
},
select() {
this.$el.children[0].select()
},
valueChanged(value) {
this.$emit('input', value)
}
},
}
</script>

View file

@ -1,28 +0,0 @@
// Import vue component
import NumericInput from './NumericInput.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('NumericInput', NumericInput);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default NumericInput

View file

@ -1,200 +0,0 @@
<template>
<div>
<byjove-model-crud ref="modelCrud"
model-name="Product"
:label-renderer="renderLabel"
:allow-edit="allowEdit"
:mode="mode"
@refresh="record => { product = record }"
v-show="!scannerSubmitting"
class="block">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<div style="display: flex; flex-direction: column;">
<b-field label="Brand">
{{ product.brand_name }}
</b-field>
<b-field label="Description">
{{ product.description }}
</b-field>
<b-field label="Size">
{{ product.size }}
</b-field>
</div>
<img :src="product.image_url" />
</div>
<slot name="default-panels" :product="product">
<b-collapse class="card"
animation="slide"
:open="false"
aria-id="contentIdForVendors">
<template #trigger="props">
<div class="card-header"
role="button"
aria-controls="contentIdForVendors"
:aria-expanded="props.open">
<p class="card-header-title">
Vendors
</p>
<a class="card-header-icon">
<b-icon pack="fas"
:icon="props.open ? 'angle-up' : 'angle-down'">
</b-icon>
</a>
</div>
</template>
<div class="card-content">
<div class="content">
<b-field label="Preferred">
{{ product.vendor_name }} @ {{ product.default_unit_cost_display }}
</b-field>
<b-table :data="product.costs">
<b-table-column label="Vendor"
field="vendor_name"
v-slot="props">
{{ props.row.vendor_name }}
</b-table-column>
<b-table-column label="Unit Cost"
field="unit_cost"
v-slot="props">
{{ props.row.unit_cost }}
</b-table-column>
</b-table>
</div>
</div>
</b-collapse>
<b-collapse class="card"
animation="slide"
:open="false"
aria-id="contentIdForPrices">
<template #trigger="props">
<div class="card-header"
role="button"
aria-controls="contentIdForPrices"
:aria-expanded="props.open">
<p class="card-header-title">
Prices
</p>
<a class="card-header-icon">
<b-icon pack="fas"
:icon="props.open ? 'angle-up' : 'angle-down'">
</b-icon>
</a>
</div>
</template>
<div class="card-content">
<div class="content">
<b-field label="Reg. Price">
{{ product.unit_price_display }}
</b-field>
<b-field label="Sale Price"
v-if="product.sale_price">
{{ product.sale_price_display }}
</b-field>
<b-field label="Sale Ends"
v-if="product.sale_price">
{{ product.sale_ends_display }}
</b-field>
<b-field label="TPR Price"
v-if="product.tpr_price">
{{ product.tpr_price_display }}
</b-field>
<b-field label="TPR Ends"
v-if="product.tpr_price">
{{ product.tpr_ends_display }}
</b-field>
</div>
</div>
</b-collapse>
</slot>
<slot name="extra-panels" :product="product"></slot>
</byjove-model-crud>
<byjove-scanner-input ref="scannerInput"
class="block"
@submit="scannerSubmit">
</byjove-scanner-input>
</div>
</template>
<script>
import ByjoveModelCrud from '../model-crud'
import ByjoveScannerInput from '../scanner-input'
export default {
name: 'Product',
props: {
mode: String,
allowEdit: {
type: Boolean,
default: false,
},
},
components: {
ByjoveModelCrud,
ByjoveScannerInput,
},
data: function() {
return {
product: {},
scannerSubmitting: false,
}
},
methods: {
renderLabel(product) {
return product.product_key
},
scannerSubmit(entry) {
this.scannerSubmitting = true
let url = '/api/products/quick-lookup'
let params = {entry: entry}
this.$http.get(url, {params: params}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: response.data.error,
type: 'is-danger',
})
} else {
this.$refs.scannerInput.clear()
this.$refs.modelCrud.clear()
this.$router.push(`/products/${response.data.product.uuid}`)
}
this.scannerSubmitting = false
}, response => {
this.$buefy.toast.open({
message: "Unknown error!",
type: 'is-danger',
})
this.scannerSubmitting = false
})
},
},
}
</script>

View file

@ -1,55 +0,0 @@
<template>
<byjove-model-index model-name="Product"
:want-initial-results="wantInitialResults"
:allow-create="allowCreate">
<byjove-scanner-input @submit="scannerSubmit">
</byjove-scanner-input>
</byjove-model-index>
</template>
<script>
import ByjoveModelIndex from '../model-index'
import ByjoveScannerInput from '../scanner-input'
export default {
name: 'ByjoveProducts',
components: {
ByjoveModelIndex,
ByjoveScannerInput,
},
props: {
wantInitialResults: {
type: Boolean,
default: false,
},
allowCreate: {
type: Boolean,
default: false,
},
},
methods: {
scannerSubmit(entry) {
let url = '/api/products/quick-lookup'
let params = {entry: entry}
this.$http.get(url, {params: params}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: response.data.error,
type: 'is-danger',
})
} else {
this.$router.push(`/products/${response.data.product.uuid}`)
}
}, response => {
this.$buefy.toast.open({
message: "Unknown error!",
type: 'is-danger',
})
})
},
},
}
</script>

View file

@ -1,29 +0,0 @@
import ByjoveProducts from './ByjoveProducts'
import ByjoveProduct from './ByjoveProduct'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveProducts', ByjoveProducts);
Vue.component('ByjoveProduct', ByjoveProduct);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
export {ByjoveProducts}
export {ByjoveProduct}

View file

@ -1,409 +0,0 @@
<template>
<div class="receiving">
<nav class="breadcrumb">
<ul>
<li>
<router-link :to="getIndexURL()">Receiving</router-link>
</li>
<li>
<router-link :to="getBatchURL()">{{ row.batch_id_str }}</router-link>
</li>
<li>
&nbsp; {{ row[productKey] }}
</li>
</ul>
</nav>
<div style="display: flex;">
<div style="flex-grow: 1;">
<p class="has-text-weight-bold">
{{ row.brand_name }}
</p>
<p class="has-text-weight-bold">
{{ row.description }} {{ row.size }}
</p>
<p v-if="row.invoice_number"
class="has-text-weight-bold">
Invoice # {{ row.invoice_number }}
</p>
<p v-if="allowCases"
class="has-text-weight-bold">
1 CS = {{ row.case_quantity || 1 }} {{ row.unit_uom }}
</p>
</div>
<div>
<img :src="row.image_url" />
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div v-if="row.order_quantities_known"
style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">ordered</span>
<span v-if="allowCases">{{ row.cases_ordered || 0 }} /</span>
<span>{{ row.units_ordered || 0}}</span>
</div>
<div v-if="row.order_quantities_known"
style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">shipped</span>
<span v-if="allowCases">{{ row.cases_shipped || 0 }} /</span>
<span>{{ row.units_shipped || 0}}</span>
</div>
<div style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">received</span>
<span v-if="allowCases">{{ row.cases_received || 0 }} /</span>
<span>{{ row.units_received || 0}}</span>
</div>
</div>
<div class="column">
<div style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">damaged</span>
<span v-if="allowCases">{{ row.cases_damaged || 0 }} /</span>
<span>{{ row.units_damaged || 0}}</span>
</div>
<div v-if="allowExpired"
style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">expired</span>
<span v-if="allowCases">{{ row.cases_expired || 0 }} /</span>
<span>{{ row.units_expired || 0}}</span>
</div>
<div style="display: flex; gap: 0.3rem;">
<span style="flex-grow: 1;">missing</span>
<span v-if="allowCases">{{ row.cases_missing || 0 }} /</span>
<span>{{ row.units_missing || 0}}</span>
</div>
</div>
</div>
<div v-if="row.received_alert"
class="has-text-danger">
<br />
<p>{{ row.received_alert }}</p>
</div>
<div v-if="row.unexpected_alert"
class="has-text-danger">
<br />
<p>{{ row.unexpected_alert }}</p>
</div>
<slot name="warnings" :row="row"></slot>
<br />
<div v-if="shouldShowQuickReceive">
<slot name="quick-receive">
<!-- only show quick-receive if we have an identifiable product -->
<div class="buttons">
<b-button v-if="row.quick_receive_all"
type="is-primary"
@click="addQuickAmount(row.quick_receive_quantity, row.quick_receive_uom)"
expanded>
{{ row.quick_receive_text }}
</b-button>
<div v-if="!row.quick_receive_all"
style="margin: auto;">
<b-button v-if="allowCases"
type="is-primary"
@click="addQuickAmount(1, 'CS')"
expanded
style="margin-bottom: 1rem;">
Receive 1 CS
</b-button>
<div>
<b-button type="is-primary"
size="is-small"
@click="addQuickAmount(1, row.unit_uom)">
1 {{ row.unit_uom }}
</b-button>
<b-button type="is-primary"
size="is-small"
@click="addQuickAmount(3, row.unit_uom)">
3 {{ row.unit_uom }}
</b-button>
<b-button type="is-primary"
size="is-small"
@click="addQuickAmount(6, row.unit_uom)">
6 {{ row.unit_uom }}
</b-button>
<b-button type="is-primary"
size="is-small"
@click="addQuickAmount(12, row.unit_uom)">
12 {{ row.unit_uom }}
</b-button>
</div>
</div>
</div>
</slot> <!-- quick-receive -->
<p class="has-text-centered is-italic">OR</p>
<br />
</div>
<b-field grouped>
<b-field class="control">
<numeric-input v-if="allowDecimalQuantities"
v-model="inputQuantity"
custom-class="receiving-quantity-input" />
<b-input v-else
v-model="inputQuantity"
type="number"
custom-class="receiving-quantity-input" />
</b-field>
<b-field class="control">
<b-radio-button v-model="inputUOM"
native-value="CS"
:disabled="!allowCases">
<span>CS</span>
</b-radio-button>
<b-radio-button v-model="inputUOM"
:native-value="row.unit_uom">
<span>{{ row.unit_uom }}</span>
</b-radio-button>
</b-field>
</b-field>
<b-field>
<b-radio-button v-model="inputType"
native-value="received">
<span>RCVD</span>
</b-radio-button>
<b-radio-button v-model="inputType"
native-value="damaged">
<span>DMGD</span>
</b-radio-button>
<b-radio-button v-model="inputType"
native-value="expired"
:disabled="!allowExpired">
<span>EXPD</span>
</b-radio-button>
<b-radio-button v-model="inputType"
native-value="missing">
<span>DNR</span>
</b-radio-button>
<!-- TODO: maybe some day... -->
<!-- <b-radio-button v-model="inputType" -->
<!-- native-value="mispick" -->
<!-- disabled> -->
<!-- <span>MSPK</span> -->
<!-- </b-radio-button> -->
</b-field>
<b-field v-if="inputType == 'expired'"
label="Expiration Date">
<b-input v-model="expirationDate"
type="date">
</b-input>
</b-field>
<div class="buttons">
<div style="margin: auto;">
<b-button type="is-primary"
@click="addAmount()"
:disabled="addAmountDisabled">
Add Amount
</b-button>
<b-button type="is-warning"
@click="subtractAmount()">
Subtract
</b-button>
</div>
</div>
<p class="has-text-centered is-italic">OR</p>
<br />
<div class="buttons">
<b-button type="is-primary"
tag="router-link"
:to="`/receiving/rows/${row.uuid}`">
View this Row
</b-button>
<b-button type="is-primary"
disabled>
Edit this Row
</b-button>
<b-button type="is-danger"
disabled>
Delete this Row
</b-button>
</div>
</div>
</template>
<script>
import NumericInput from '../numeric-input/NumericInput.vue'
export default {
name: 'ByjoveReceiving',
components: {NumericInput},
props: {
productKey: {
type: String,
default: 'upc',
},
allowCases: {
type: Boolean,
default: true,
},
allowDecimalQuantities: {
type: Boolean,
default: false,
},
allowExpired: {
type: Boolean,
default: true,
},
},
data() {
return {
row: {},
inputQuantity: 1,
inputUOM: null,
inputType: 'received',
expirationDate: null,
}
},
computed: {
addAmountDisabled: function() {
if (this.inputType == 'expired' && !this.expirationDate) {
return true
}
return false
},
shouldShowQuickReceive() {
if (!this.row.quick_receive) {
return false
}
if (!this.row.product_uuid) {
return false
}
return true
},
},
mounted() {
this.fetch(this.$route.params.uuid)
},
methods: {
getIndexURL() {
return '/receiving/'
},
getBatchURL() {
return `/receiving/${this.row.batch_uuid}`
},
fetch(uuid) {
this.$http.get(`/api/receiving-batch-row/${uuid}`).then(response => {
this.row = response.data.data
if (this.allowCases && this.row.cases_unconfirmed) {
this.inputQuantity = this.row.cases_unconfirmed
this.inputUOM = 'CS'
} else {
this.inputQuantity = this.row.units_unconfirmed || 1
this.inputUOM = this.row.unit_uom
}
}, response => {
if (response.status == 403) { // forbidden; redirect to model index
this.$buefy.toast.open({
message: "You do not have permission to access that page.",
type: 'is-danger',
})
this.$router.push(this.getIndexURL())
} else {
this.$buefy.toast.open({
message: "Failed to fetch page data!",
type: 'is-danger',
})
}
})
},
addAmount() {
let amount = this.inputQuantity
amount = this.allowDecimalQuantities ? parseFloat(amount) : parseInt(amount)
if (!amount) {
this.$buefy.toast.open({
message: "Please specify an amount",
type: 'is-info',
})
return
}
let url = `/api/receiving-batch-row/${this.row.uuid}/receive`
let params = {
row: this.row.uuid,
mode: this.inputType,
cases: null,
units: null,
expiration_date: this.expirationDate,
quick_receive: false,
}
if (this.inputUOM == 'CS') {
params.cases = amount
} else {
params.units = amount
}
this.$http.post(url, params).then(response => {
if (!response.data.error) {
this.$router.push(`/receiving/${this.row.batch_uuid}`)
} else {
this.$buefy.toast.open({
message: response.data.error,
type: 'is-danger',
})
}
}, response => {
this.$buefy.toast.open({
message: "Save failed: unknown error",
type: 'is-danger',
})
})
},
addQuickAmount(quantity, uom) {
this.inputQuantity = quantity
this.inputUOM = uom
this.addAmount()
},
subtractAmount() {
this.inputQuantity = -this.inputQuantity
this.addAmount()
},
},
}
</script>
<style>
table.receiving-quantities td {
padding: 0px 10px 0px 0px;
}
.input.receiving-quantity-input {
width: 6rem;
}
</style>

View file

@ -1,28 +0,0 @@
// Import vue component
import ByjoveReceiving from './ByjoveReceiving.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveReceiving', ByjoveReceiving);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveReceiving

View file

@ -1,60 +0,0 @@
<template>
<div class="scanner-input">
<b-input v-model="quickEntry"
placeholder="Scan/Enter Code"
icon="search"
@keypress.native="quickKey">
</b-input>
</div>
</template>
<script>
export default {
name: 'ByjoveScannerInput',
data() {
return {
quickEntry: '',
}
},
mounted() {
window.addEventListener('keypress', this.globalKey)
},
beforeDestroy() {
window.removeEventListener('keypress', this.globalKey)
},
methods: {
clear() {
this.quickEntry = ''
},
globalKey(event) {
if (event.target.tagName == 'BODY') {
// mimic keyboard wedge
if (event.charCode >= 48 && event.charCode <= 57) { // numeric (qwerty)
this.$nextTick(() => {
this.quickEntry += event.key
})
} else if (event.keyCode == 13) { // enter
this.submitQuickEntry()
}
}
},
quickKey(event) {
if (event.keyCode == 13) { // enter
this.submitQuickEntry()
}
},
submitQuickEntry() {
if (this.quickEntry) {
this.$emit('submit', this.quickEntry)
}
},
},
}
</script>

View file

@ -1,28 +0,0 @@
// Import vue component
import ByjoveScannerInput from './ByjoveScannerInput.vue'
// Declare install function executed by Vue.use()
export function install(Vue) {
if (install.installed) return;
install.installed = true;
Vue.component('ByjoveScannerInput', ByjoveScannerInput);
}
// Create module definition for Vue.use()
const plugin = {
install,
};
// Auto-install when vue is found (eg. in browser via <script> tag)
let GlobalVue = null;
if (typeof window !== 'undefined') {
GlobalVue = window.Vue;
} else if (typeof global !== 'undefined') {
GlobalVue = global.Vue;
}
if (GlobalVue) {
GlobalVue.use(plugin);
}
// To allow use as module (npm/webpack/etc.) export component
export default ByjoveScannerInput

View file

@ -1,5 +1,4 @@
export * from './plugin'
export * from './components'
export {ByjoveStoreConfig} from './store'

View file

@ -1,68 +0,0 @@
export function ByjovePlugin(Vue) {
Vue.mixin({
methods: {
$byjoveLoginUser(user, permissions) {
// put user info in app store
this.$store.commit('SET_USER', user)
this.$store.commit('SET_USER_IS_ADMIN', user.is_admin)
// note, this should already be false
// this.$store.commit('SET_USER_IS_ROOT', false)
// maybe update permissions
if (permissions !== undefined) {
this.$store.commit('SET_PERMISSIONS', permissions)
}
},
// TODO: deprecate / remove
$loginUser(user, permission) {
this.$byjoveLoginUser(user, permission)
},
$byjoveLogoutUser() {
// remove user info from app store
this.$store.commit('SET_USER', {})
this.$store.commit('SET_USER_IS_ADMIN', false)
this.$store.commit('SET_USER_IS_ROOT', false)
},
// TODO: deprecate / remove
$logoutUser() {
this.$byjoveLogoutUser()
},
$byjoveHasPerm(name) {
// if user is root then assume permission
if (this.$store.state.user_is_root) {
return true
}
// otherwise do true perm check for user
return this.$store.state.permissions.includes(name)
},
// TODO: deprecate / remove
$hasPerm(name) {
return this.$byjoveHasPerm(name)
},
}
})
Vue.filter('byjoveCurrency', function (value) {
if (typeof value !== 'number') {
return value
}
var formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
// TODO: make this configurable?
minimumFractionDigits: 2,
})
return formatter.format(value)
})
}

View file

@ -1,32 +1,20 @@
export let ByjoveStoreConfig = {
state: {
session_established: false,
user: {},
user_is_admin: false,
user_is_root: false,
user: null,
user_is_root: null,
permissions: [],
settings: {},
},
mutations: {
SET_SESSION_ESTABLISHED(state, established) {
state.session_established = established
},
SET_USER(state, user) {
state.user = user
},
SET_USER_IS_ADMIN(state, is_admin) {
state.user_is_admin = is_admin
},
SET_USER_IS_ROOT(state, is_root) {
state.user_is_root = is_root
},
SET_PERMISSIONS(state, permissions) {
state.permissions = permissions
},
SET_SETTINGS(state, settings) {
state.settings = settings
},
},
actions: {
// TODO: should we define the logic here, for fetching current session

View file

@ -13,5 +13,4 @@ def release(c):
"""
c.run("find . -name '*~' -delete")
c.run('npm run build')
otp = c.run('otp npm').stdout.strip()
c.run('npm publish --otp {}'.format(otp))
c.run('npm publish')