Files
dougal-software/lib/www/client/source/src/views/LineList.vue
D. Berge c8b2047483 Refactor client-side access checks.
Go from a Vuex based to a mixin based approach.
2025-07-12 11:31:38 +02:00

497 lines
14 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<v-container fluid>
<v-card>
<v-card-title>
<v-toolbar flat>
<v-toolbar-title>Preplots</v-toolbar-title>
<v-spacer></v-spacer>
<v-text-field
v-model="filter"
append-icon="mdi-magnify"
label="Filter"
single-line
clearable
hint="Filter by line number, first or last shotpoint or remarks. Use incr or + / decr or - to show only incrementing / decrementing lines"
></v-text-field>
</v-toolbar>
</v-card-title>
<v-card-text>
<v-menu v-if="writeaccess()"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
absolute
offset-y
>
<v-list dense v-if="contextMenuItem">
<template v-if="!selectOn">
<v-list-item @click="setNTBA" v-if="contextMenuItem.ntba || (contextMenuItem.num_points == contextMenuItem.na)">
<v-list-item-title v-if="contextMenuItem.ntba"
title="Mark the line as part of the acquisition plan"
>Unset NTBA</v-list-item-title>
<v-list-item-title v-else
title="Mark the line as not to be acquired"
>Set NTBA</v-list-item-title>
</v-list-item>
<v-list-item @click="setComplete" v-if="contextMenuItem.na && (contextMenuItem.num_points != contextMenuItem.na || contextMenuItem.tba != contextMenuItem.na)">
<v-list-item-title v-if="contextMenuItem.tba != contextMenuItem.na"
title="Mark any remaining points as pending acquisition"
>Unset line complete</v-list-item-title>
<v-list-item-title v-else
title="Mark any remaining points as not to be acquired"
>Set line complete</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan" v-if="!contextMenuItem.ntba && !isPlanned(contextMenuItem)">
<v-list-item-title>Add to plan</v-list-item-title>
</v-list-item>
<v-list-item @click="removeFromPlan" v-if="isPlanned(contextMenuItem)">
<v-list-item-title>Remove from plan</v-list-item-title>
</v-list-item>
<v-list-item @click="showLineColourDialog">
<v-list-item-title>Set colour</v-list-item-title>
</v-list-item>
</template>
<template v-else>
<v-list-item @click="showLineColourDialog">
<v-list-item-title>Set colour</v-list-item-title>
</v-list-item>
</template>
<v-divider></v-divider>
<v-list-item>
<v-list-item-action-text>Multi-select</v-list-item-action-text>
<v-list-item-action><v-checkbox v-model="selectOn"></v-checkbox></v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
<v-dialog
v-model="colourPickerShow"
max-width="300"
>
<v-card>
<v-card-title>Choose line colour</v-card-title>
<v-card-text>
<v-color-picker
v-model="selectedColour"
show-swatches
hide-canvas
hide-inputs
hide-mode-switch
@update:color="selectedColour.alpha = 0.5"
>
</v-color-picker>
</v-card-text>
<v-card-actions>
<v-btn small
@click="selectedColour=null; setLineColour()"
>
Clear
</v-btn>
<v-spacer></v-spacer>
<v-btn small
@click="setLineColour"
>
Set
</v-btn>
<v-spacer></v-spacer>
<v-btn small
@click="colourPickerShow=false"
>
Cancel
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-data-table
:headers="headers"
:items="items"
:items-per-page.sync="itemsPerPage"
:server-items-length="lineCount"
item-key="line"
:search="filter"
:loading="linesLoading"
:options.sync="options"
fixed-header
:footer-props='{itemsPerPageOptions: [ 10, 25, 50, 100, 500, -1 ], showFirstLastPage: true}'
:item-class="itemClass"
:show-select="selectOn"
v-model="selectedRows"
@click:row="setActiveItem"
@contextmenu:row="contextMenu"
>
<template v-slot:item.status="{item}">
<dougal-line-status
:preplot="item"
:sequences="sequences.filter(s => s.line == item.line)"
:sequence-href="(s) => `/projects/${$route.params.project}/log/sequence/${s.sequence}`"
:planned-sequences="plannedSequences.filter(s => s.line == item.line)"
:planned-sequence-href="() => `/projects/${$route.params.project}/plan`"
:pending-reshoots="null"
:pending-reshoot-href="null"
>
<template v-slot:empty>
<div v-if="!item.ntba" class="sequence" title="Virgin"></div>
<div v-else class="sequence ntba" title="Not to be acquired"><span>NTBA</span></div>
</template>
</dougal-line-status>
</template>
<template v-slot:item.tba="{item, value}">
<span :class="!value && (item.na ? 'warning--text' : 'success--text')">{{ value }}</span>
</template>
<template v-slot:item.length="props">
<span>{{ Math.round(props.value) }} m</span>
</template>
<template v-slot:item.azimuth="props">
<span>{{ props.value.toFixed(2) }} °</span>
</template>
<template v-slot:item.remarks="{item}">
<v-text-field v-if="edit && edit.line == item.line && edit.key == 'remarks'"
type="text"
v-model="edit.value"
prepend-icon="mdi-restore"
append-outer-icon="mdi-content-save-edit-outline"
clearable
@click:prepend="edit.value = item.remarks; edit = null"
@click:append-outer="edit = null"
>
</v-text-field>
<div v-else>
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
<v-btn v-if="writeaccess() && edit === null"
icon
small
title="Edit"
:disabled="linesLoading"
@click="editItem(item, 'remarks')"
>
<v-icon small>mdi-square-edit-outline</v-icon>
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</v-container>
</template>
<style lang="stylus" scoped>
.ntba
background-image repeating-linear-gradient(-45deg, #ff00004d, #ff000080 10px, #d3d3d31a 10px, #d3d3d314 20px)
cursor not-allowed
display flex
align-items center
justify-content center
</style>
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalLineStatus from '@/components/line-status';
import AccessMixin from '@/mixins/access';
export default {
name: "LineList",
components: {
DougalLineStatus
},
mixins: [
AccessMixin
],
data () {
return {
headers: [
{
value: "line",
text: "Line"
},
{
value: "status",
text: "Status"
},
{
value: "fsp",
text: "FSP",
align: "end"
},
{
value: "lsp",
text: "LSP",
align: "end"
},
{
value: "num_points",
text: "Points",
align: "end"
},
{
value: "na",
text: "Virgin",
align: "end"
},
{
value: "tba",
text: "Remaining",
align: "end"
},
{
value: "length",
text: "Length",
align: "end"
},
{
value: "azimuth",
text: "Azimuth",
align: "end"
},
{
value: "remarks",
text: "Remarks"
}
],
items: [],
selectOn: false,
selectedRows: [],
filter: "",
options: {},
lineCount: null,
//sequences: [],
activeItem: null,
edit: null, // {line, key, value}
queuedReload: false,
itemsPerPage: 25,
// Context menu stuff
contextMenuShow: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuItem: null,
// Colour picker stuff
colourPickerShow: false,
selectedColour: null,
styles: null
}
},
computed: {
...mapGetters(['user', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
},
watch: {
options: {
handler () {
this.fetchLines();
},
deep: true
},
async lines () {
await this.fetchLines();
},
async edit (newVal, oldVal) {
if (newVal === null && oldVal !== null) {
const item = this.items.find(i => i.line == oldVal.line);
// Get around this Vuetify feature
// https://github.com/vuetifyjs/vuetify/issues/4144
if (oldVal.value === null) oldVal.value = "";
if (item && item[oldVal.key] != oldVal.value) {
if (await this.saveItem(oldVal)) {
item[oldVal.key] = oldVal.value;
} else {
this.edit = oldVal;
}
}
}
},
filter (newVal, oldVal) {
if (newVal?.toLowerCase() != oldVal?.toLowerCase()) {
this.fetchLines();
}
},
itemsPerPage (newVal, oldVal) {
localStorage.setItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`, newVal);
},
user (newVal, oldVal) {
this.itemsPerPage = Number(localStorage.getItem(`dougal/prefs/${this.user?.name}/${this.$route.params.project}/${this.$options.name}/items-per-page`)) || 25;
}
},
methods: {
itemClass (item) {
const colourClass = item.meta.colour ? "bg-clr-"+item.meta.colour.slice(1) : null;
if (colourClass && ![...this.styles.cssRules].some(i => i.selectorText == "."+colourClass)) {
const rule = `.${colourClass} { background-color: ${item.meta.colour}; }`;
this.styles.insertRule(rule);
}
return [
item.meta.colour ? colourClass : "",
(this.activeItem == item && !this.edit) ? 'blue accent-1 elevation-3' : ''
];
},
isPlanned(item) {
return this.sequences.find(i => i.line == item.line && i.status == 'planned');
},
contextMenu (e, {item}) {
e.preventDefault();
this.contextMenuShow = false;
this.contextMenuX = e.clientX;
this.contextMenuY = e.clientY;
this.contextMenuItem = item;
this.$nextTick( () => this.contextMenuShow = true );
},
setNTBA () {
this.removeFromPlan();
this.saveItem({
line: this.contextMenuItem.line,
key: 'ntba',
value: !this.contextMenuItem.ntba
})
},
setComplete () {
this.saveItem({
line: this.contextMenuItem.line,
key: 'complete',
value: this.contextMenuItem.na && this.contextMenuItem.tba == this.contextMenuItem.na
})
},
async addToPlan () {
const payload = {
sequence: null,
line: this.contextMenuItem.line,
fsp: this.contextMenuItem.fsp,
lsp: this.contextMenuItem.lsp
}
const url = `/project/${this.$route.params.project}/plan`;
const init = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload
}
await this.api([url, init]);
},
async removeFromPlan () {
const plannedLine = this.sequences.find(i => i.status == "planned" && i.line == this.contextMenuItem.line);
if (plannedLine && plannedLine.sequence) {
const url = `/project/${this.$route.params.project}/plan/${plannedLine.sequence}`;
const init = {
method: "DELETE"
}
await this.api([url, init]);
}
},
showLineColourDialog () {
this.selectedColour = this.contextMenuItem.meta.colour
? {hexa: this.contextMenuItem.meta.colour}
: null;
this.colourPickerShow = true;
},
setLineColour () {
const items = this.selectOn ? this.selectedRows : [ this.contextMenuItem ];
const colour = this.selectedColour ? this.selectedColour.hex+"80" : null;
this.selectedRows = [];
this.selectOn = false;
for (const item of items) {
if (colour) {
item.meta.colour = colour;
} else {
delete item.meta.colour;
}
this.saveItem({line: item.line, key: "meta", value: item.meta});
this.colourPickerShow = false;
}
},
editItem (item, key) {
this.edit = {
line: item.line,
key,
value: item[key]
}
},
async saveItem (edit) {
if (!edit) return;
try {
const url = `/project/${this.$route.params.project}/line/${edit.line}`;
const init = {
method: "PATCH",
body: {
[edit.key]: edit.value
}
};
let res;
await this.api([url, init, (e, r) => res = r]);
return res && res.ok;
} catch (err) {
return false;
}
},
setActiveItem (item) {
this.activeItem = this.activeItem == item
? null
: item;
},
async fetchLines (opts = {}) {
const options = {
text: this.filter,
...this.options
};
const res = await this.getLines([this.$route.params.project, options]);
this.items = res.lines;
this.lineCount = res.count;
},
...mapActions(["api", "getLines"])
},
mounted () {
this.fetchLines();
// Initialise stylesheet
const el = document.createElement("style");
document.head.appendChild(el);
this.styles = document.styleSheets[document.styleSheets.length-1];
}
}
</script>