9 Commits
8.1.2 ... 8.1.3

Author SHA1 Message Date
4a6d8eff6f case 4468 - reviews keep the type of the record they're for; a review without one is refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:24:52 -07:00
8f6428467c case 4600 - subscription expiring reminders only for trial servers in service; expiry hidden for paid servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:24:55 -07:00
cb7f79342d case 4663 - subscription server ssh link opens ssh://root@server in Windows Terminal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:49:53 -07:00
d28588dca9 case 4756 - Ctrl+; inserts today's date, Ctrl+Shift+; date and time, in any text field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:44:14 -07:00
91cb511d6f case 4764 - open a case by its number from the case list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:37:36 -07:00
ac23f1f264 case 4714 - duplicating a case gives the copy its own case number, open and created now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:20:30 -07:00
d286650eaa case 4604 - case form: Close and Reopen buttons instead of editing the dates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:59:09 -07:00
d1813f6d63 case 4742 - mass close and reopen of cases from the list extensions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:59:09 -07:00
15c2b157df version bump 8.1.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 08:27:20 -07:00
11 changed files with 313 additions and 41 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "sockeye",
"version": "8.1.2",
"version": "8.1.3",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",

View File

@@ -242,6 +242,7 @@ import gznotify from "./components/gznotify";
import openObjectHandler from "./api/open-object-handler";
import notifyPoll from "./api/notifypoll";
import { processLogout } from "./api/authutil";
import insertDateShortcut from "./api/insert-date-shortcut";
export default {
components: {
gzconfirm,
@@ -310,10 +311,12 @@ export default {
window.$gz.dialog.wireUpEventHandlers(this);
openObjectHandler.wireUpEventHandlers(this);
window.$gz.translation.setVuetifyDefaultLanguageElements(this);
insertDateShortcut.wireUp();
},
beforeDestroy() {
//UNWIRE ALL EVENT HANDLERS FROM GZEVENTBUS
window.$gz.eventBus.$off();
insertDateShortcut.unwire();
},
mounted() {
const vm = this;

View File

@@ -0,0 +1,67 @@
/////////////////////////////////////////////////////////////////////
// case 4756: keyboard shortcut to insert today's date in any text field
// Ctrl+; inserts "2026-10-26 - "
// Ctrl+Shift+; inserts "2026-10-26 14:30 - "
// at the cursor (replacing any selected text) of the text box being typed in, on the browser's clock
// (same keys as Excel and Google Sheets)
//
function pad(n) {
return n < 10 ? "0" + n : "" + n;
}
export function dateText(now, withTime) {
let text = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)}`;
if (withTime) {
text += ` ${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
return text + " - ";
}
function isPlainTextField(el) {
if (!el || el.readOnly || el.disabled) {
return false;
}
//the report editor (Monaco) has its own keyboard handling
if (el.closest && el.closest(".monaco-editor")) {
return false;
}
if (el.tagName === "TEXTAREA") {
return true;
}
return (
el.tagName === "INPUT" &&
["text", "search", ""].includes(
(el.getAttribute("type") || "").toLowerCase()
)
);
}
export function handleKeydown(e) {
if (!e.ctrlKey || e.altKey || e.metaKey || e.code !== "Semicolon") {
return;
}
const el = document.activeElement;
if (!isPlainTextField(el)) {
return;
}
e.preventDefault();
el.setRangeText(
dateText(new Date(), e.shiftKey),
el.selectionStart,
el.selectionEnd,
"end"
);
//let Vue's v-model see the change, as typing would
el.dispatchEvent(new Event("input", { bubbles: true }));
}
export default {
wireUp() {
document.addEventListener("keydown", handleKeydown);
},
unwire() {
document.removeEventListener("keydown", handleKeydown);
}
};

View File

@@ -1,5 +1,5 @@
export default {
version: "8.1.2",
version: "8.1.3",
//case 4630: the year is set when the client is built (vue.config.js)
copyright: `© ${process.env.VUE_APP_BUILD_YEAR}, Ground Zero Tech-Works Inc.`
};

View File

@@ -0,0 +1,114 @@
<template>
<!-- case 4742: close or reopen the selected cases in one go (Sockeye only, so plain English, no translation keys) -->
<v-expansion-panel v-if="available == true">
<v-expansion-panel-header
disable-icon-rotate
expand-icon="$sockiCheckCircle"
>Close / reopen cases</v-expansion-panel-header
>
<v-expansion-panel-content>
<v-btn
color="blue darken-1"
text
:disabled="jobActive"
:loading="jobActive && !opening"
data-cy="extCloseCases"
@click="doAction(false)"
>Close</v-btn
>
<v-btn
color="blue darken-1"
text
:disabled="jobActive"
:loading="jobActive && opening"
data-cy="extOpenCases"
@click="doAction(true)"
>Reopen</v-btn
>
<v-btn
v-if="jobActive"
color="red darken-1"
text
@click="requestCancel()"
>
{{ $sock.t("Cancel") }}</v-btn
><span v-if="jobActive">{{ progress }}</span>
</v-expansion-panel-content>
</v-expansion-panel>
</template>
<script>
export default {
props: {
dataListSelection: { type: Object, default: null }
},
data: () => ({
jobActive: false,
opening: false,
currentJobId: null,
progress: ""
}),
computed: {
available() {
return (
this.dataListSelection != null &&
this.dataListSelection.SockType == window.$gz.type.GZCase &&
window.$gz.role.getRights(window.$gz.type.GZCase).change
);
}
},
methods: {
async requestCancel() {
await window.$gz.api.upsert(
"job-operations/request-cancel",
this.currentJobId
);
},
async doAction(open) {
const vm = this;
//Clear any possible prior errors
vm.$emit("ext-show-job-log", "clear");
vm.opening = open;
const url = `job-operations/batch-close-cases?open=${open}`;
try {
this.progress = "";
let jobId = await window.$gz.api.upsert(url, this.dataListSelection);
if (jobId.error) {
throw new Error(window.$gz.errorHandler.errorToString(jobId, vm));
}
this.currentJobId = jobId.jobId;
vm.jobActive = true;
let jobProgress = {};
while (vm.jobActive == true) {
await window.$gz.util.sleepAsync(2000);
jobProgress = await window.$gz.api.get(
`job-operations/progress/${this.currentJobId}`
);
if (jobProgress.error) {
throw new Error(
window.$gz.errorHandler.errorToString(jobProgress, vm)
);
}
jobProgress = jobProgress.data;
this.progress = jobProgress.progress;
if (jobProgress.jobStatus == 4 || jobProgress.jobStatus == 0) {
if (jobProgress.jobStatus == 4) {
//emit job id and event to parent for log viewing
vm.$emit("ext-show-job-log", jobId);
}
throw new Error("Job failed");
}
if (jobProgress.jobStatus == 3) {
vm.jobActive = false;
}
}
//Here if it's completed successfully
window.$gz.eventBus.$emit("notify-success", vm.$sock.t("JobCompleted"));
vm.$emit("ext-close-refresh");
} catch (error) {
vm.jobActive = false;
window.$gz.eventBus.$emit("notify-error", vm.$sock.t("JobFailed"));
}
}
}
};
</script>

View File

@@ -24,6 +24,11 @@
<v-expansion-panels focusable>
<ExtensionTags :data-list-selection="dataListSelection" />
<ExtensionExport :data-list-selection="dataListSelection" />
<ExtensionCloseCases
:data-list-selection="dataListSelection"
@ext-close-refresh="close({ refresh: true })"
@ext-show-job-log="handleError($event)"
/>
<ExtensionDelete
:data-list-selection="dataListSelection"
@ext-close-refresh="close({ refresh: true })"
@@ -43,12 +48,14 @@
import ExtensionTags from "./extension-tags-control.vue";
import ExtensionExport from "./extension-export-control.vue";
import ExtensionDelete from "./extension-delete-control.vue";
import ExtensionCloseCases from "./extension-close-cases-control.vue";
export default {
components: {
ExtensionTags,
ExtensionExport,
ExtensionDelete
ExtensionDelete,
ExtensionCloseCases
},
data: () => ({
isVisible: false,

View File

@@ -27,39 +27,18 @@ export default {
methods: {
openUrl() {
if (this.value) {
window.open(`ssh:${this.value}`, "ssh");
//case 4663: ssh://root@host is the form OpenSSH understands; "ssh:host" (the PuTTY-era link) is not
window.open(`ssh://root@${this.value.trim()}`, "ssh");
}
}
}
};
/*
TO USE THIS:
Must have a SSH URI handler set in registry on windows to open putty like this:
TO USE THIS (case 4663): Windows must open ssh:// links in Windows Terminal, which runs Windows' own OpenSSH
(it uses the keys in the KeePassXC SSH agent). The registry handler, under HKEY_CLASSES_ROOT\ssh\shell\open\command:
----------------
Windows Registry Editor Version 5.00
"C:\Users\<you>\AppData\Local\Microsoft\WindowsApps\wt.exe" ssh "%1"
[HKEY_CLASSES_ROOT\ssh]
@="URL:SSH Protocol"
"URL Protocol"=""
[HKEY_CLASSES_ROOT\ssh\DefaultIcon]
@="%%ProgramFiles%%\\PuTTY\\putty.exe"
[HKEY_CLASSES_ROOT\ssh\shell]
[HKEY_CLASSES_ROOT\ssh\shell\open]
[HKEY_CLASSES_ROOT\ssh\shell\open\command]
@="C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -Command \"$val='%l'; $val = $val.TrimEnd('/');if ($val.StartsWith('ssh://')) { $val = $val.SubString(6) };if ($val.StartsWith('ssh:')) { $val = $val.SubString(4) }; & 'C:\\Program Files\\PuTTY\\putty.exe' \"$val\"\""
----------------
Also in putty need to set default to use pagent agent and then open the keys in KeePass so relies on a lot specific to us but it works great
https://documentation.help/PuTTY/config-saving.html#S4.1.2
C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -Command "$val='%l'; $val = $val.TrimEnd('/');if ($val.StartsWith('ssh://')) { $val = $val.SubString(6) };if ($val.StartsWith('ssh:')) { $val = $val.SubString(4) }; & 'C:\Program Files\PuTTY\putty.exe' "$val""
plus the "URL Protocol" value on HKEY_CLASSES_ROOT\ssh so the browser offers it.
*/
</script>

View File

@@ -1,5 +1,44 @@
<template>
<div>
<!-- case 4764: open a case by its number, as AyaNova's work order list (Sockeye only, so plain English) -->
<v-row dense justify="center">
<v-dialog
v-model="openDialog"
persistent
max-width="600px"
@keydown.esc="openDialog = false"
@keydown.enter="openCase()"
>
<v-card>
<v-card-title>
<span class="text-h5">Open case</span>
</v-card-title>
<v-card-text>
<v-text-field
v-model="openCaseNumber"
label="Case number"
autofocus
required
type="number"
data-cy="openCaseNumber"
></v-text-field>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue darken-1" text @click="openDialog = false">{{
$sock.t("Cancel")
}}</v-btn>
<v-btn
color="blue darken-1"
text
:disabled="!openCaseNumber"
@click="openCase()"
>{{ $sock.t("OK") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</v-row>
<gz-report-selector ref="reportSelector"></gz-report-selector>
<gz-extensions
ref="extensions"
@@ -36,7 +75,9 @@ export default {
preFilterMode: null,
objectId: null,
aForType: null,
name: null
name: null,
openDialog: false,
openCaseNumber: null
};
},
created() {
@@ -72,6 +113,27 @@ export default {
handleSelected(selected) {
this.selectedItems = selected;
},
async openCase() {
//case 4764
if (!this.openCaseNumber) {
return;
}
const res = await window.$gz.api.get(
"gzcase/id-from-number/" + this.openCaseNumber
);
if (res.error) {
window.$gz.eventBus.$emit(
"notify-warning",
`Case ${this.openCaseNumber} was not found`
);
return;
}
this.openDialog = false;
this.$router.push({
name: "gzcase-edit",
params: { recordid: res.data }
});
},
clearPreFilter() {
this.clientCriteria = null;
this.preFilterMode = null;
@@ -96,6 +158,10 @@ async function clickHandler(menuItem) {
params: { recordid: 0 }
});
break;
case "open":
m.vm.openCaseNumber = null;
m.vm.openDialog = true;
break;
case "extensions":
{
const res = await m.vm.$refs.extensions.open(
@@ -175,6 +241,14 @@ function generateMenu(vm) {
key: FORM_KEY + ":extensions",
vm: vm
});
//case 4764
menuOptions.menuItems.push({
title: "Open case",
notrans: true,
icon: "$sockiCoffee",
key: FORM_KEY + ":open",
vm: vm
});
window.$gz.eventBus.$emit("menu-change", menuOptions);
}
</script>

View File

@@ -66,23 +66,33 @@
v-model="obj.created"
:label="$sock.t('Created')"
:rules="[form().required(this, 'created')]"
:readonly="formState.readOnly"
:readonly="true"
:error-messages="form().serverErrors(this, 'created')"
data-cy="created"
@input="fieldValueChanged('created')"
></gz-date-time-picker>
</v-col>
<!-- case 4604: closed is set by the Close / Reopen button, not picked (Sockeye only, so plain English) -->
<v-col cols="12" sm="6" lg="4" xl="3">
<gz-date-time-picker
ref="closed"
v-model="obj.closed"
:label="$sock.t('GZCaseClosed')"
:readonly="formState.readOnly"
:readonly="true"
:error-messages="form().serverErrors(this, 'closed')"
data-cy="closed"
@input="fieldValueChanged('closed')"
></gz-date-time-picker>
<v-btn
v-if="!formState.readOnly && obj.id != 0"
color="primary"
text
:disabled="formState.loading"
data-cy="closeOpen"
@click="setClosed(obj.closed == null)"
>{{ obj.closed == null ? "Close case" : "Reopen case" }}</v-btn
>
</v-col>
<!-- --------------------------------- -->
@@ -212,6 +222,10 @@ export default {
this.obj = this.$route.params.obj;
this.obj.concurrency = undefined;
this.obj.id = 0;
//case 4714: a duplicate is a new, open case: the server gives it the next case number when saved
this.obj.caseId = 0;
this.obj.closed = null;
this.obj.created = window.$gz.locale.nowUTC8601String();
this.obj.name = `${this.obj.name} - ${window.$gz.translation.get(
"Copy"
)}`;
@@ -259,6 +273,15 @@ export default {
form() {
return window.$gz.form;
},
async setClosed(close) {
//case 4604: Close sets the closed date to now, saves the case and goes back; Open clears it and saves
this.obj.closed = close ? window.$gz.locale.nowUTC8601String() : null;
this.fieldValueChanged("closed");
await this.submit();
if (close && !this.formState.dirty) {
this.$router.go(-1);
}
},
fieldValueChanged(ref) {
if (
this.formState.ready &&

View File

@@ -143,7 +143,8 @@
></gz-date-time-picker>
</v-col>
<v-col cols="12" sm="6" lg="4" xl="3">
<!-- case 4600: expiry only applies to trial servers; a paid server renews monthly -->
<v-col v-if="obj.trial" cols="12" sm="6" lg="4" xl="3">
<v-row no-gutters>
<v-col cols="1">
<v-menu offset-y>
@@ -1206,6 +1207,10 @@ export default {
loading: true
});
window.$gz.form.deleteAllErrorBoxErrors(vm);
//case 4600: expiry is hidden and unused for a paid server, but the record still needs a date
if (!vm.obj.trial && !vm.obj.subscriptionExpire) {
vm.obj.subscriptionExpire = window.$gz.locale.nowUTC8601String();
}
if (vm.obj.operatingSystem == null) {
vm.obj.operatingSystem = "Ubuntu 22.10";
}

View File

@@ -4,7 +4,7 @@
<div v-if="formState.ready">
<gz-error :error-box-message="formState.errorBoxMessage"></gz-error>
<v-form ref="form">
<div v-if="obj.aType" class="mb-6">
<div v-if="obj.sockType" class="mb-6">
<v-icon large @click="navToTarget()">{{ iconForType }}</v-icon
><span class="text-h5" @click="navToTarget()"> {{ name }}</span>
</div>
@@ -191,7 +191,7 @@ export default {
userId: window.$gz.store.state.userId,
assignedByUserId: window.$gz.store.state.userId,
objectId: null,
aType: null,
sockType: null, //case 4468: the server field is SockType; the form used AyaNova's AType, so reviews were saved with no type
overDue: false
},
formState: {
@@ -222,7 +222,7 @@ export default {
},
iconForType() {
return window.$gz.util.iconForType(this.obj.aType);
return window.$gz.util.iconForType(this.obj.sockType);
},
hasSupervisorRole: function() {
return window.$gz.role.hasRole([
@@ -298,10 +298,10 @@ export default {
vm.obj.objectId = window.$gz.util.stringToIntOrNull(
vm.$route.params.objectId
);
vm.obj.aType = window.$gz.util.stringToIntOrNull(
vm.obj.sockType = window.$gz.util.stringToIntOrNull(
vm.$route.params.aType
);
if (!vm.obj.objectId || !vm.obj.aType) {
if (!vm.obj.objectId || !vm.obj.sockType) {
throw "SockType and ObjectId are required to create a review";
}
}
@@ -337,7 +337,7 @@ export default {
methods: {
navToTarget: function() {
window.$gz.eventBus.$emit("openobject", {
type: this.obj.aType,
type: this.obj.sockType,
id: this.obj.objectId
});
},
@@ -515,7 +515,7 @@ async function clickHandler(menuItem) {
name: "review-edit",
params: {
recordid: 0,
aType: m.vm.obj.aType,
aType: m.vm.obj.sockType,
objectId: m.vm.obj.objectId,
name: m.vm.name
}