rework client manager and modals to composition api. Improved client/site delete
This commit is contained in:
@@ -103,6 +103,7 @@ class ClientSerializer(ModelSerializer):
|
||||
class SiteTreeSerializer(ModelSerializer):
|
||||
maintenance_mode = ReadOnlyField(source="has_maintenanace_mode_agents")
|
||||
failing_checks = ReadOnlyField(source="has_failing_checks")
|
||||
agent_count = ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
@@ -113,6 +114,7 @@ class ClientTreeSerializer(ModelSerializer):
|
||||
sites = SiteTreeSerializer(many=True, read_only=True)
|
||||
maintenance_mode = ReadOnlyField(source="has_maintenanace_mode_agents")
|
||||
failing_checks = ReadOnlyField(source="has_failing_checks")
|
||||
agent_count = ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Client
|
||||
|
||||
@@ -223,8 +223,8 @@ class GetUpdateDeleteSite(APIView):
|
||||
# only run tasks if it affects clients
|
||||
if site.agent_count > 0 and "move_to_site" in request.query_params.keys():
|
||||
agents = Agent.objects.filter(site=site)
|
||||
site = get_object_or_404(Site, pk=request.query_params["move_to_site"])
|
||||
agents.update(site=site)
|
||||
new_site = get_object_or_404(Site, pk=request.query_params["move_to_site"])
|
||||
agents.update(site=new_site)
|
||||
generate_agent_checks_task.delay(all=True, create_tasks=True)
|
||||
|
||||
elif site.agent_count > 0:
|
||||
|
||||
@@ -171,7 +171,10 @@ class GetAddCustomFields(APIView):
|
||||
permission_classes = [IsAuthenticated, EditCoreSettingsPerms]
|
||||
|
||||
def get(self, request):
|
||||
fields = CustomField.objects.all()
|
||||
if "model" in request.query_params.keys():
|
||||
fields = CustomField.objects.filter(model=request.query_params["model"])
|
||||
else:
|
||||
fields = CustomField.objects.all()
|
||||
return Response(CustomFieldSerializer(fields, many=True).data)
|
||||
|
||||
def patch(self, request):
|
||||
|
||||
@@ -9,9 +9,53 @@ export async function fetchClients() {
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
export async function fetchClient(id) {
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl}/${id}/`)
|
||||
return data
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
export async function saveClient(payload) {
|
||||
const { data } = await axios.post(`${baseUrl}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function editClient(id, payload) {
|
||||
const { data } = await axios.put(`${baseUrl}/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function removeClient(id, params = {}) {
|
||||
const { data } = await axios.delete(`${baseUrl}/${id}/`, { params: params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchSites() {
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl}/sites/`)
|
||||
return data
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSite(id) {
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl}/sites/${id}/`)
|
||||
return data
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
export async function saveSite(payload) {
|
||||
const { data } = await axios.post(`${baseUrl}/sites/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function editSite(id, payload) {
|
||||
const { data } = await axios.put(`${baseUrl}/sites/${id}/`, payload)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function removeSite(id, params = {}) {
|
||||
const { data } = await axios.delete(`${baseUrl}/sites/${id}/`, { params: params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function fetchCustomFields(params = {}) {
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl}/customfields/`, { params: params })
|
||||
return data
|
||||
} catch (e) { }
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
export async function uploadMeshAgent(payload) {
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" @hide="onHide">
|
||||
<div class="q-dialog-plugin" style="width: 90vw; max-width: 90vw">
|
||||
<q-card>
|
||||
<q-bar>
|
||||
<q-btn @click="getClients" class="q-mr-sm" dense flat push icon="refresh" />Clients Manager
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<div class="q-pa-sm" style="min-height: 65vh; max-height: 65vh">
|
||||
<div class="q-gutter-sm">
|
||||
<q-btn label="New" dense flat push unelevated no-caps icon="add" @click="showAddClient" />
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
:rows="clients"
|
||||
:columns="columns"
|
||||
v-model:pagination="pagination"
|
||||
row-key="id"
|
||||
binary-state-sort
|
||||
hide-pagination
|
||||
virtual-scroll
|
||||
:rows-per-page-options="[0]"
|
||||
no-data-label="No Clients"
|
||||
:table-class="{ 'table-bgcolor': !$q.dark.isActive, 'table-bgcolor-dark': $q.dark.isActive }"
|
||||
class="settings-tbl-sticky"
|
||||
>
|
||||
<!-- body slots -->
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" class="cursor-pointer" @dblclick="showEditClient(props.row)">
|
||||
<!-- context menu -->
|
||||
<q-menu context-menu>
|
||||
<q-list dense style="min-width: 200px">
|
||||
<q-item clickable v-close-popup @click="showEditClient(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="edit" />
|
||||
</q-item-section>
|
||||
<q-item-section>Edit</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showClientDeleteModal(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="delete" />
|
||||
</q-item-section>
|
||||
<q-item-section>Delete</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup @click="showAddSite(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="add" />
|
||||
</q-item-section>
|
||||
<q-item-section>Add Site</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup>
|
||||
<q-item-section>Close</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
<!-- name -->
|
||||
<q-td>
|
||||
{{ props.row.name }}
|
||||
</q-td>
|
||||
<q-td>
|
||||
<span
|
||||
style="cursor: pointer; text-decoration: underline"
|
||||
class="text-primary"
|
||||
@click="showSitesTable(props.row)"
|
||||
>Show Sites</span
|
||||
>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mixins from "@/mixins/mixins";
|
||||
import ClientsForm from "@/components/modals/clients/ClientsForm";
|
||||
import SitesForm from "@/components/modals/clients/SitesForm";
|
||||
import DeleteClient from "@/components/modals/clients/DeleteClient";
|
||||
import SitesTable from "@/components/modals/clients/SitesTable";
|
||||
|
||||
export default {
|
||||
name: "ClientsManager",
|
||||
emits: ["hide", "ok", "cancel"],
|
||||
mixins: [mixins],
|
||||
data() {
|
||||
return {
|
||||
clients: [],
|
||||
columns: [
|
||||
{ name: "name", label: "Name", field: "name", align: "left" },
|
||||
{ name: "sites", label: "Sites", field: "sites", align: "left" },
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 0,
|
||||
sortBy: "name",
|
||||
descending: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getClients() {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.get("clients/")
|
||||
.then(r => {
|
||||
this.clients = r.data;
|
||||
this.$q.loading.hide();
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
showClientDeleteModal(client) {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: client,
|
||||
type: "client",
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getClients();
|
||||
});
|
||||
},
|
||||
showEditClient(client) {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: ClientsForm,
|
||||
componentProps: {
|
||||
client: client,
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getClients();
|
||||
});
|
||||
},
|
||||
showAddClient() {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: ClientsForm,
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getClients();
|
||||
});
|
||||
},
|
||||
showAddSite(client) {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
client: client.id,
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getClients();
|
||||
});
|
||||
},
|
||||
showSitesTable(client) {
|
||||
this.$q.dialog({
|
||||
component: SitesTable,
|
||||
componentProps: {
|
||||
client: client,
|
||||
},
|
||||
});
|
||||
},
|
||||
show() {
|
||||
this.$refs.dialog.show();
|
||||
},
|
||||
hide() {
|
||||
this.$refs.dialog.hide();
|
||||
},
|
||||
onHide() {
|
||||
this.$emit("hide");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getClients();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -189,9 +189,9 @@
|
||||
import DialogWrapper from "@/components/ui/DialogWrapper";
|
||||
import DebugLog from "@/components/logs/DebugLog";
|
||||
import PendingActions from "@/components/modals/logs/PendingActions";
|
||||
import ClientsManager from "@/components/ClientsManager";
|
||||
import ClientsForm from "@/components/modals/clients/ClientsForm";
|
||||
import SitesForm from "@/components/modals/clients/SitesForm";
|
||||
import ClientsManager from "@/components/clients/ClientsManager";
|
||||
import ClientsForm from "@/components/clients/ClientsForm";
|
||||
import SitesForm from "@/components/clients/SitesForm";
|
||||
import UpdateAgents from "@/components/modals/agents/UpdateAgents";
|
||||
import ScriptManager from "@/components/scripts/ScriptManager";
|
||||
import EditCoreSettings from "@/components/modals/coresettings/EditCoreSettings";
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
row-key="id"
|
||||
v-model:pagination="pagination"
|
||||
no-data-label="No Roles"
|
||||
hide-bottom
|
||||
>
|
||||
<template v-slot:top-left="props">
|
||||
<template v-slot:top="props">
|
||||
<q-btn color="primary" icon="add" label="New Role" @click="showAddRoleModal" />
|
||||
</template>
|
||||
<template v-slot:body="props">
|
||||
@@ -64,6 +63,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useQuasar, useDialogPluginComponent } from "quasar";
|
||||
import { fetchRoles, removeRole } from "@/api/accounts";
|
||||
@@ -86,6 +86,8 @@ export default {
|
||||
// setup quasar
|
||||
const $q = useQuasar();
|
||||
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// permission manager logic
|
||||
const roles = ref([]);
|
||||
const pagination = ref({
|
||||
rowsPerPage: 50,
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
virtual-scroll
|
||||
no-data-label="No checks"
|
||||
>
|
||||
<template v-slot:loading>
|
||||
<q-inner-loading showing color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- table top slot -->
|
||||
<template v-slot:top>
|
||||
<q-btn class="q-mr-sm" dense flat push @click="getChecks" icon="refresh" />
|
||||
@@ -95,6 +99,7 @@
|
||||
<template v-slot:header-cell-policystatus="props">
|
||||
<q-th auto-width :props="props"></q-th>
|
||||
</template>
|
||||
|
||||
<!-- body slots -->
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" class="cursor-pointer" @dblclick="showCheckModal(props.row.check_type, props.row)">
|
||||
@@ -394,6 +399,7 @@ export default {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function showEventInfo(data) {
|
||||
|
||||
142
web/src/components/clients/ClientsForm.vue
Normal file
142
web/src/components/clients/ClientsForm.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 40vw">
|
||||
<q-bar>
|
||||
{{ !!client ? `Editing ${client.name}` : "Adding Client" }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-form @submit="submit">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
outlined
|
||||
dense
|
||||
v-model="state.name"
|
||||
label="Name"
|
||||
:rules="[val => (val && val.length > 0) || '*Required']"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="!client">
|
||||
<q-input
|
||||
:rules="[val => !!val || '*Required']"
|
||||
outlined
|
||||
dense
|
||||
v-model="site.name"
|
||||
label="Default first site"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<div class="q-pl-sm text-h6" v-if="customFields.length > 0">Custom Fields</div>
|
||||
<q-card-section v-for="field in customFields" :key="field.id">
|
||||
<CustomField v-model="custom_fields[field.name]" :field="field" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat label="Cancel" v-close-popup />
|
||||
<q-btn :loading="loading" dense flat push label="Save" color="primary" type="submit" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useDialogPluginComponent } from "quasar";
|
||||
import { fetchClient, saveClient, editClient } from "@/api/clients";
|
||||
import { fetchCustomFields } from "@/api/core";
|
||||
import { formatCustomFields } from "@/utils/format";
|
||||
import { notifySuccess } from "@/utils/notify";
|
||||
|
||||
// ui imports
|
||||
import CustomField from "@/components/ui/CustomField";
|
||||
|
||||
export default {
|
||||
name: "ClientsForm",
|
||||
emits: [...useDialogPluginComponent.emits],
|
||||
components: {
|
||||
CustomField,
|
||||
},
|
||||
props: {
|
||||
client: Object,
|
||||
},
|
||||
setup(props) {
|
||||
// setup vuex
|
||||
const store = useStore();
|
||||
|
||||
// setup quasar dialog
|
||||
const { dialogRef, onDialogOK, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// clients form logic
|
||||
const state = !!props.client ? ref(Object.assign({}, props.client)) : ref({ name: "" });
|
||||
const site = ref({ name: "" });
|
||||
const custom_fields = ref({});
|
||||
const customFields = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
const data = {
|
||||
client: state.value,
|
||||
site: site.value,
|
||||
custom_fields: formatCustomFields(customFields.value, custom_fields.value),
|
||||
};
|
||||
try {
|
||||
const result = !!props.client ? await editClient(props.client.id, data) : await saveClient(data);
|
||||
notifySuccess(result);
|
||||
onDialogOK();
|
||||
store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function getClientCustomFieldValues() {
|
||||
loading.value = true;
|
||||
const data = await fetchClient(props.client.id);
|
||||
|
||||
for (let field of customFields.value) {
|
||||
const value = data.custom_fields.find(value => value.field === field.id);
|
||||
|
||||
if (field.type === "multiple") {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = [];
|
||||
} else if (field.type === "checkbox") {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = false;
|
||||
} else {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = "";
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const fields = await fetchCustomFields({ model: "client" });
|
||||
customFields.value = fields.filter(field => !field.hide_in_ui);
|
||||
if (props.client) getClientCustomFieldValues();
|
||||
});
|
||||
|
||||
return {
|
||||
// reactive data
|
||||
state,
|
||||
site,
|
||||
customFields,
|
||||
custom_fields,
|
||||
loading,
|
||||
|
||||
// methods
|
||||
submit,
|
||||
|
||||
// quasar dialog
|
||||
dialogRef,
|
||||
onDialogHide,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
223
web/src/components/clients/ClientsManager.vue
Normal file
223
web/src/components/clients/ClientsManager.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 70vw">
|
||||
<q-bar>
|
||||
<q-btn @click="getClients" class="q-mr-sm" dense flat push icon="refresh" />Clients Manager
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-table
|
||||
:rows="clients"
|
||||
:columns="columns"
|
||||
:table-class="{ 'table-bgcolor': !$q.dark.isActive, 'table-bgcolor-dark': $q.dark.isActive }"
|
||||
class="settings-tbl-sticky"
|
||||
style="height: 70vh"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: false }"
|
||||
dense
|
||||
row-key="id"
|
||||
binary-state-sort
|
||||
virtual-scroll
|
||||
:rows-per-page-options="[0]"
|
||||
no-data-label="No Clients"
|
||||
:loading="loading"
|
||||
>
|
||||
<!-- top slot -->
|
||||
<template v-slot:top>
|
||||
<q-btn label="New" dense flat push no-caps icon="add" @click="showAddClient" />
|
||||
</template>
|
||||
|
||||
<!-- loading slot -->
|
||||
<template v-slot:loading>
|
||||
<q-inner-loading showing color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- body slots -->
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" class="cursor-pointer" @dblclick="showEditClient(props.row)">
|
||||
<!-- context menu -->
|
||||
<q-menu context-menu>
|
||||
<q-list dense style="min-width: 200px">
|
||||
<q-item clickable v-close-popup @click="showEditClient(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="edit" />
|
||||
</q-item-section>
|
||||
<q-item-section>Edit</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showClientDeleteModal(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="delete" />
|
||||
</q-item-section>
|
||||
<q-item-section>Delete</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup @click="showAddSite(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="add" />
|
||||
</q-item-section>
|
||||
<q-item-section>Add Site</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup>
|
||||
<q-item-section>Close</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
<!-- name -->
|
||||
<q-td>
|
||||
{{ props.row.name }}
|
||||
</q-td>
|
||||
<q-td>
|
||||
<span
|
||||
style="cursor: pointer; text-decoration: underline"
|
||||
class="text-primary"
|
||||
@click="showSitesTable(props.row)"
|
||||
>Show Sites ({{ props.row.sites.length }})</span
|
||||
>
|
||||
</q-td>
|
||||
<q-td>{{ props.row.agent_count }}</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar, useDialogPluginComponent } from "quasar";
|
||||
import { fetchClients, removeClient } from "@/api/clients";
|
||||
import { notifySuccess } from "@/utils/notify";
|
||||
|
||||
// ui imports
|
||||
import ClientsForm from "@/components/clients/ClientsForm";
|
||||
import SitesForm from "@/components/clients/SitesForm";
|
||||
import DeleteClient from "@/components/clients/DeleteClient";
|
||||
import SitesTable from "@/components/clients/SitesTable";
|
||||
|
||||
// static data
|
||||
const columns = [
|
||||
{ name: "name", label: "Name", field: "name", align: "left" },
|
||||
{ name: "sites", label: "Sites", field: "sites", align: "left" },
|
||||
{ name: "agent_count", label: "Total Agents", field: "agent_count", align: "left" },
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "ClientsManager",
|
||||
emits: [...useDialogPluginComponent.emits],
|
||||
setup(props) {
|
||||
// setup vuex
|
||||
const store = useStore();
|
||||
|
||||
// setup quasar dialog
|
||||
const $q = useQuasar();
|
||||
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// clients manager logic
|
||||
const clients = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function getClients() {
|
||||
loading.value = true;
|
||||
clients.value = await fetchClients();
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function showClientDeleteModal(client) {
|
||||
// agents are still assigned to client. Need to open modal to select which site to move to
|
||||
if (client.agent_count > 0) {
|
||||
$q.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: client,
|
||||
type: "client",
|
||||
},
|
||||
}).onOk(getClients);
|
||||
|
||||
// can delete the client since there are no agents
|
||||
} else {
|
||||
$q.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Delete client: ${client.name}.`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
}).onOk(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await removeClient(client.id);
|
||||
notifySuccess(result);
|
||||
await getClients();
|
||||
store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showEditClient(client) {
|
||||
$q.dialog({
|
||||
component: ClientsForm,
|
||||
componentProps: {
|
||||
client: client,
|
||||
},
|
||||
}).onOk(getClients);
|
||||
}
|
||||
|
||||
function showAddClient() {
|
||||
$q.dialog({
|
||||
component: ClientsForm,
|
||||
}).onOk(getClients);
|
||||
}
|
||||
|
||||
function showAddSite(client) {
|
||||
$q.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
client: client.id,
|
||||
},
|
||||
}).onOk(getClients);
|
||||
}
|
||||
|
||||
function showSitesTable(client) {
|
||||
$q.dialog({
|
||||
component: SitesTable,
|
||||
componentProps: {
|
||||
client: client,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(getClients);
|
||||
|
||||
return {
|
||||
// reactive data
|
||||
clients,
|
||||
loading,
|
||||
|
||||
// non-reactive data
|
||||
columns,
|
||||
|
||||
// methods
|
||||
getClients,
|
||||
showClientDeleteModal,
|
||||
showEditClient,
|
||||
showAddClient,
|
||||
showAddSite,
|
||||
showSitesTable,
|
||||
|
||||
// quasar dialog
|
||||
dialogRef,
|
||||
onDialogHide,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
132
web/src/components/clients/DeleteClient.vue
Normal file
132
web/src/components/clients/DeleteClient.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||
<q-card class="q-dialog-plugin">
|
||||
<q-bar>
|
||||
Delete {{ object.name }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-form @submit="submit">
|
||||
<q-card-section v-if="siteOptions.length === 0">
|
||||
There are no valid sites to move agents to. Add another site and try again
|
||||
</q-card-section>
|
||||
<q-card-section v-if="siteOptions.length > 0">
|
||||
<tactical-dropdown
|
||||
label="Site to move agents to"
|
||||
outlined
|
||||
v-model="site"
|
||||
:options="siteOptions"
|
||||
mapOptions
|
||||
:rules="[val => !!val || 'Select the site that the agents should be moved to']"
|
||||
hint="The client you are deleting has agents assigned to it. Select a Site below to move the agents to."
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat push label="Cancel" v-close-popup />
|
||||
<q-btn
|
||||
:loading="loading"
|
||||
:disable="siteOptions.length === 0"
|
||||
dense
|
||||
flat
|
||||
push
|
||||
label="Move"
|
||||
color="primary"
|
||||
type="submit"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar, useDialogPluginComponent } from "quasar";
|
||||
import { notifySuccess } from "@/utils/notify";
|
||||
import { fetchClients, removeClient, removeSite } from "@/api/clients";
|
||||
import { formatSiteOptions } from "@/utils/format";
|
||||
|
||||
// ui imports
|
||||
import TacticalDropdown from "@/components/ui/TacticalDropdown";
|
||||
|
||||
export default {
|
||||
name: "DeleteClient",
|
||||
emits: [...useDialogPluginComponent.emits],
|
||||
components: {
|
||||
TacticalDropdown,
|
||||
},
|
||||
props: {
|
||||
object: !Object,
|
||||
type: !String,
|
||||
},
|
||||
setup(props) {
|
||||
// setup vuex
|
||||
const store = useStore();
|
||||
|
||||
// setup quasar dialog
|
||||
const $q = useQuasar();
|
||||
const { dialogRef, onDialogOK, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// delete client logic
|
||||
const loading = ref(false);
|
||||
const site = ref(null);
|
||||
const siteOptions = ref([]);
|
||||
|
||||
function submit() {
|
||||
$q.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Deleting ${props.type} ${props.object.name}. ${props.object.agent_count} agents will be moved to the selected site`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
}).onOk(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result =
|
||||
props.type === "client"
|
||||
? await removeClient(props.object.id, { move_to_site: site.value })
|
||||
: await removeSite(props.object.id, { move_to_site: site.value });
|
||||
notifySuccess(result);
|
||||
onDialogOK();
|
||||
store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
async function getSiteOptions() {
|
||||
const clients = await fetchClients();
|
||||
|
||||
if (props.type === "client") {
|
||||
// filter out client that is being deleted
|
||||
siteOptions.value = Object.freeze(formatSiteOptions(clients.filter(client => client.id !== props.object.id)));
|
||||
} else {
|
||||
// filter out site that is being dleted
|
||||
clients.forEach(client => (client.sites = client.sites.filter(site => site.id !== props.object.id)));
|
||||
siteOptions.value = Object.freeze(formatSiteOptions(clients));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(getSiteOptions);
|
||||
|
||||
return {
|
||||
site,
|
||||
loading,
|
||||
siteOptions,
|
||||
|
||||
// methods
|
||||
submit,
|
||||
|
||||
// quasar dialog
|
||||
dialogRef,
|
||||
onDialogHide,
|
||||
onDialogOK,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
143
web/src/components/clients/SitesForm.vue
Normal file
143
web/src/components/clients/SitesForm.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 60vw">
|
||||
<q-bar>
|
||||
{{ !!site ? `Editing ${site.name}` : "Adding Site" }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-form @submit="submit">
|
||||
<q-card-section>
|
||||
<tactical-dropdown
|
||||
v-model="state.client"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
outlined
|
||||
mapOptions
|
||||
:rules="[val => !!val || 'Client is required']"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input :rules="[val => !!val || 'Name is required']" outlined dense v-model="state.name" label="Name" />
|
||||
</q-card-section>
|
||||
|
||||
<div class="q-pl-sm text-h6" v-if="customFields.length > 0">Custom Fields</div>
|
||||
<q-card-section v-for="field in customFields" :key="field.id">
|
||||
<CustomField v-model="custom_fields[field.name]" :field="field" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat push label="Cancel" v-close-popup />
|
||||
<q-btn :loading="loading" dense flat push label="Save" color="primary" type="submit" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useDialogPluginComponent } from "quasar";
|
||||
import { useClientDropdown } from "@/composables/clients";
|
||||
import { fetchSite, saveSite, editSite } from "@/api/clients";
|
||||
import { fetchCustomFields } from "@/api/core";
|
||||
import { formatCustomFields } from "@/utils/format";
|
||||
import { notifySuccess } from "@/utils/notify";
|
||||
|
||||
// ui imports
|
||||
import CustomField from "@/components/ui/CustomField";
|
||||
import TacticalDropdown from "@/components/ui/TacticalDropdown";
|
||||
|
||||
export default {
|
||||
name: "SitesForm",
|
||||
emits: [...useDialogPluginComponent.emits],
|
||||
components: {
|
||||
CustomField,
|
||||
TacticalDropdown,
|
||||
},
|
||||
props: {
|
||||
site: Object,
|
||||
client: Number,
|
||||
},
|
||||
setup(props) {
|
||||
// setup vuex
|
||||
const store = useStore();
|
||||
|
||||
// setup quasar dialog
|
||||
const { dialogRef, onDialogOK, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// setup dropdowns
|
||||
const { clientOptions } = useClientDropdown(true);
|
||||
|
||||
// sites for logic
|
||||
const state = !!props.site ? ref(Object.assign({}, props.site)) : ref({ client: props.client, name: "" });
|
||||
const custom_fields = ref({});
|
||||
const customFields = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
loading.value = true;
|
||||
const data = {
|
||||
site: state.value,
|
||||
custom_fields: formatCustomFields(customFields.value, custom_fields.value),
|
||||
};
|
||||
try {
|
||||
const result = !!props.site ? await editSite(props.site.id, data) : await saveSite(data);
|
||||
notifySuccess(result);
|
||||
onDialogOK();
|
||||
store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function getSiteCustomFieldValues() {
|
||||
loading.value = true;
|
||||
const data = await fetchSite(props.site.id);
|
||||
|
||||
for (let field of customFields.value) {
|
||||
const value = data.custom_fields.find(value => value.field === field.id);
|
||||
|
||||
if (field.type === "multiple") {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = [];
|
||||
} else if (field.type === "checkbox") {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = false;
|
||||
} else {
|
||||
if (value) custom_fields.value[field.name] = value.value;
|
||||
else custom_fields.value[field.name] = "";
|
||||
}
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const fields = await fetchCustomFields({ model: "site" });
|
||||
customFields.value = fields.filter(field => !field.hide_in_ui);
|
||||
if (props.site) getSiteCustomFieldValues();
|
||||
});
|
||||
|
||||
return {
|
||||
// reactive data
|
||||
state,
|
||||
loading,
|
||||
custom_fields,
|
||||
customFields,
|
||||
clientOptions,
|
||||
|
||||
// methods
|
||||
submit,
|
||||
|
||||
// quasar dialog
|
||||
dialogRef,
|
||||
onDialogHide,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
190
web/src/components/clients/SitesTable.vue
Normal file
190
web/src/components/clients/SitesTable.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<q-dialog ref="dialogRef" @hide="onDialogHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 60vw">
|
||||
<q-bar>
|
||||
<q-btn @click="getSites" class="q-mr-sm" dense flat push icon="refresh" />Sites for {{ client.name }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-table
|
||||
dense
|
||||
:rows="sites"
|
||||
:columns="columns"
|
||||
:pagination="{ rowsPerPage: 0, sortBy: 'name', descending: true }"
|
||||
row-key="id"
|
||||
binary-state-sort
|
||||
virtual-scroll
|
||||
:rows-per-page-options="[0]"
|
||||
no-data-label="No Sites"
|
||||
:table-class="{ 'table-bgcolor': !$q.dark.isActive, 'table-bgcolor-dark': $q.dark.isActive }"
|
||||
class="settings-tbl-sticky"
|
||||
style="height: 65vh"
|
||||
:loading="loading"
|
||||
>
|
||||
<template v-slot:top>
|
||||
<q-btn label="New" dense flat push unelevated no-caps icon="add" @click="showAddSite" />
|
||||
</template>
|
||||
|
||||
<!-- loading slot -->
|
||||
<template v-slot:loading>
|
||||
<q-inner-loading showing color="primary" />
|
||||
</template>
|
||||
|
||||
<!-- body slots -->
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" class="cursor-pointer" @dblclick="showEditSite(props.row)">
|
||||
<!-- context menu -->
|
||||
<q-menu context-menu>
|
||||
<q-list dense style="min-width: 200px">
|
||||
<q-item clickable v-close-popup @click="showEditSite(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="edit" />
|
||||
</q-item-section>
|
||||
<q-item-section>Edit</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showSiteDeleteModal(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="delete" />
|
||||
</q-item-section>
|
||||
<q-item-section>Delete</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup>
|
||||
<q-item-section>Close</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
<!-- name -->
|
||||
<q-td>
|
||||
{{ props.row.name }}
|
||||
</q-td>
|
||||
<!-- agent count -->
|
||||
<q-td>{{ props.row.agent_count }}</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// composition imports
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useStore } from "vuex";
|
||||
import { useQuasar, useDialogPluginComponent } from "quasar";
|
||||
import { fetchClient, removeSite } from "@/api/clients";
|
||||
import { notifySuccess } from "@/utils/notify";
|
||||
|
||||
// ui imports
|
||||
import SitesForm from "@/components/clients/SitesForm";
|
||||
import DeleteClient from "@/components/clients/DeleteClient";
|
||||
|
||||
// static data
|
||||
const columns = [
|
||||
{ name: "name", label: "Name", field: "name", align: "left" },
|
||||
{ name: "agent_count", label: "Total Agents", field: "agent_count", align: "left" },
|
||||
];
|
||||
|
||||
export default {
|
||||
name: "SitesTable",
|
||||
emits: [...useDialogPluginComponent.emits],
|
||||
props: {
|
||||
client: !Object,
|
||||
},
|
||||
setup(props) {
|
||||
// setup vuex
|
||||
const store = useStore();
|
||||
|
||||
// setup quasar dialog
|
||||
const $q = useQuasar();
|
||||
const { dialogRef, onDialogHide } = useDialogPluginComponent();
|
||||
|
||||
// sites table logic
|
||||
const sites = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function getSites() {
|
||||
loading.value = true;
|
||||
const client = await fetchClient(props.client.id);
|
||||
sites.value = client.sites;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function showSiteDeleteModal(site) {
|
||||
// agents are still assigned to client. Need to open modal to select which site to move to
|
||||
if (site.agent_count > 0) {
|
||||
$q.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: site,
|
||||
type: "site",
|
||||
},
|
||||
}).onOk(getSites);
|
||||
|
||||
// can delete the client since there are no agents
|
||||
} else {
|
||||
$q.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Delete site: ${site.name}.`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
}).onOk(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await removeSite(site.id);
|
||||
notifySuccess(result);
|
||||
await getSites();
|
||||
store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function showEditSite(site) {
|
||||
$q.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
site: site,
|
||||
},
|
||||
}).onOk(getSites);
|
||||
}
|
||||
|
||||
function showAddSite() {
|
||||
$q.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
client: props.client.id,
|
||||
},
|
||||
}).onOk(getSites);
|
||||
}
|
||||
|
||||
onMounted(getSites);
|
||||
|
||||
return {
|
||||
// reactive data
|
||||
sites,
|
||||
loading,
|
||||
|
||||
// non-reactive data
|
||||
columns,
|
||||
|
||||
// methods
|
||||
getSites,
|
||||
showSiteDeleteModal,
|
||||
showEditSite,
|
||||
showAddSite,
|
||||
|
||||
// quasar dialog
|
||||
dialogRef,
|
||||
onDialogHide,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,179 +0,0 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" @hide="onHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 60vw">
|
||||
<q-bar>
|
||||
{{ title }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-card-section>
|
||||
<q-form @submit.prevent="submit">
|
||||
<q-card-section>
|
||||
<q-input
|
||||
outlined
|
||||
dense
|
||||
v-model="localClient.name"
|
||||
label="Name"
|
||||
:rules="[val => (val && val.length > 0) || '*Required']"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="!editing">
|
||||
<q-input
|
||||
:rules="[val => !!val || '*Required']"
|
||||
outlined
|
||||
dense
|
||||
v-model="site.name"
|
||||
label="Default first site"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<div class="text-h6">Custom Fields</div>
|
||||
<q-card-section v-for="field in customFields" :key="field.id">
|
||||
<CustomField v-model="custom_fields[field.name]" :field="field" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat label="Cancel" v-close-popup />
|
||||
<q-btn dense flat label="Save" color="primary" type="submit" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomField from "@/components/ui/CustomField";
|
||||
import mixins from "@/mixins/mixins";
|
||||
export default {
|
||||
name: "ClientsForm",
|
||||
emits: ["hide", "ok", "cancel"],
|
||||
components: {
|
||||
CustomField,
|
||||
},
|
||||
mixins: [mixins],
|
||||
props: {
|
||||
client: !Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customFields: [],
|
||||
site: {
|
||||
name: "",
|
||||
},
|
||||
localClient: {
|
||||
name: "",
|
||||
},
|
||||
custom_fields: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.editing ? "Edit Client" : "Add Client";
|
||||
},
|
||||
editing() {
|
||||
return !!this.client;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (!this.editing) this.addClient();
|
||||
else this.editClient();
|
||||
},
|
||||
addClient() {
|
||||
this.$q.loading.show();
|
||||
const data = {
|
||||
client: this.localClient,
|
||||
site: this.site,
|
||||
custom_fields: this.formatCustomFields(this.customFields, this.custom_fields),
|
||||
};
|
||||
this.$axios
|
||||
.post("/clients/", data)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.$q.loading.hide();
|
||||
this.onOk();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
editClient() {
|
||||
this.$q.loading.show();
|
||||
const data = {
|
||||
client: this.localClient,
|
||||
custom_fields: this.formatCustomFields(this.customFields, this.custom_fields),
|
||||
};
|
||||
|
||||
this.$axios
|
||||
.put(`/clients/${this.client.id}/`, data)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.onOk();
|
||||
this.$q.loading.hide();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
getClient() {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.get(`/clients/${this.client.id}/`)
|
||||
.then(r => {
|
||||
this.$q.loading.hide();
|
||||
this.localClient.name = r.data.name;
|
||||
|
||||
for (let field of this.customFields) {
|
||||
const value = r.data.custom_fields.find(value => value.field === field.id);
|
||||
|
||||
if (field.type === "multiple") {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.custom_fields[field.name] = [];
|
||||
} else if (field.type === "checkbox") {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.this.custom_fields[field.name] = false;
|
||||
} else {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.custom_fields[field.name] = "";
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
refreshDashboardTree() {
|
||||
this.$store.dispatch("loadTree");
|
||||
this.$store.dispatch("getUpdatedSites");
|
||||
},
|
||||
show() {
|
||||
this.$refs.dialog.show();
|
||||
},
|
||||
hide() {
|
||||
this.$refs.dialog.hide();
|
||||
},
|
||||
onHide() {
|
||||
this.$emit("hide");
|
||||
},
|
||||
onOk() {
|
||||
this.$emit("ok");
|
||||
this.hide();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Get custom fields
|
||||
this.getCustomFields("client").then(r => {
|
||||
this.customFields = r.data.filter(field => !field.hide_in_ui);
|
||||
});
|
||||
|
||||
// Copy client prop locally
|
||||
if (this.editing) {
|
||||
this.getClient();
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,165 +0,0 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" @hide="onHide">
|
||||
<q-card class="q-dialog-plugin">
|
||||
<q-bar>
|
||||
Delete {{ object.name }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-form @submit="submit">
|
||||
<q-card-section>
|
||||
<q-select
|
||||
label="Site to move agents to"
|
||||
dense
|
||||
options-dense
|
||||
outlined
|
||||
v-model="selectedSite"
|
||||
:options="siteOptions"
|
||||
map-options
|
||||
emit-value
|
||||
:rules="[val => !!val || 'Select the site that the agents should be moved to']"
|
||||
>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-if="!scope.opt.category" v-bind="scope.itemProps" class="q-pl-lg">
|
||||
<q-item-section>
|
||||
<q-item-label v-html="scope.opt.label"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item-label v-if="scope.opt.category" v-bind="scope.itemProps" header class="q-pa-sm">{{
|
||||
scope.opt.category
|
||||
}}</q-item-label>
|
||||
</template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat label="Cancel" v-close-popup />
|
||||
<q-btn dense flat label="Delete" color="negative" type="submit" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mixins from "@/mixins/mixins";
|
||||
export default {
|
||||
name: "DeleteClient",
|
||||
emits: ["hide", "ok", "cancel"],
|
||||
mixins: [mixins],
|
||||
props: {
|
||||
object: !Object,
|
||||
type: !String,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
siteOptions: [],
|
||||
selectedSite: null,
|
||||
agentCount: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (this.type === "client") this.deleteClient();
|
||||
else this.deleteSite();
|
||||
},
|
||||
deleteClient() {
|
||||
this.$q
|
||||
.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Delete client ${this.object.name}. Agents from all sites will be moved to the selected site`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
})
|
||||
.onOk(() => {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.delete(`/clients/${this.object.id}/${this.selectedSite}/`)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.$q.loading.hide();
|
||||
this.onOk();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
});
|
||||
},
|
||||
deleteSite() {
|
||||
this.$q
|
||||
.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Delete site ${this.object.name}. Agents from all sites will be moved to the selected site`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
})
|
||||
.onOk(() => {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.delete(`/clients/sites/${this.object.id}/${this.selectedSite}/`)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.$q.loading.hide();
|
||||
this.onOk();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
});
|
||||
},
|
||||
getSites() {
|
||||
this.$axios
|
||||
.get("/clients/")
|
||||
.then(r => {
|
||||
this.agentCount = this.getAgentCount(r.data, this.type, this.object.id);
|
||||
r.data.forEach(client => {
|
||||
// remove client that is being deleted from options
|
||||
if (this.type === "client") {
|
||||
if (client.id !== this.object.id) {
|
||||
this.siteOptions.push({ category: client.name });
|
||||
|
||||
client.sites.forEach(site => {
|
||||
this.siteOptions.push({ label: site.name, value: site.id });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.siteOptions.push({ category: client.name });
|
||||
|
||||
client.sites.forEach(site => {
|
||||
if (site.id !== this.object.id) {
|
||||
this.siteOptions.push({ label: site.name, value: site.id });
|
||||
} else if (client.sites.length === 1) {
|
||||
this.siteOptions.pop();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(e => {});
|
||||
},
|
||||
refreshDashboardTree() {
|
||||
this.$store.dispatch("loadTree");
|
||||
this.$store.dispatch("getUpdatedSites");
|
||||
},
|
||||
show() {
|
||||
this.$refs.dialog.show();
|
||||
},
|
||||
hide() {
|
||||
this.$refs.dialog.hide();
|
||||
},
|
||||
onHide() {
|
||||
this.$emit("hide");
|
||||
},
|
||||
onOk() {
|
||||
this.$emit("ok");
|
||||
this.hide();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getSites();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" @hide="onHide">
|
||||
<q-card class="q-dialog-plugin" style="width: 60vw">
|
||||
<q-bar>
|
||||
{{ title }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<q-card-section>
|
||||
<q-form @submit.prevent="submit">
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="localSite.client"
|
||||
label="Client"
|
||||
:options="clientOptions"
|
||||
outlined
|
||||
dense
|
||||
options-dense
|
||||
map-options
|
||||
emit-value
|
||||
:rules="[val => !!val || 'Client is required']"
|
||||
/>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-input
|
||||
:rules="[val => !!val || 'Name is required']"
|
||||
outlined
|
||||
dense
|
||||
v-model="localSite.name"
|
||||
label="Name"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<div class="text-h6">Custom Fields</div>
|
||||
<q-card-section v-for="field in customFields" :key="field.id">
|
||||
<CustomField v-model="custom_fields[field.name]" :field="field" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn dense flat label="Cancel" v-close-popup />
|
||||
<q-btn dense flat label="Save" color="primary" type="submit" />
|
||||
</q-card-actions>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CustomField from "@/components/ui/CustomField";
|
||||
import mixins from "@/mixins/mixins";
|
||||
export default {
|
||||
name: "SitesForm",
|
||||
emits: ["hide", "ok", "cancel"],
|
||||
components: {
|
||||
CustomField,
|
||||
},
|
||||
mixins: [mixins],
|
||||
props: {
|
||||
site: !Object,
|
||||
client: !Number,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customFields: [],
|
||||
clientOptions: [],
|
||||
localSite: {
|
||||
client: null,
|
||||
name: "",
|
||||
},
|
||||
custom_fields: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.editing ? "Edit Site" : "Add Site";
|
||||
},
|
||||
editing() {
|
||||
return !!this.site;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
if (!this.editing) this.addSite();
|
||||
else this.editSite();
|
||||
},
|
||||
addSite() {
|
||||
this.$q.loading.show();
|
||||
const data = {
|
||||
site: this.localSite,
|
||||
custom_fields: this.formatCustomFields(this.customFields, this.custom_fields),
|
||||
};
|
||||
this.$axios
|
||||
.post("/clients/sites/", data)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.$q.loading.hide();
|
||||
this.onOk();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
editSite() {
|
||||
this.$q.loading.show();
|
||||
const data = {
|
||||
site: this.localSite,
|
||||
custom_fields: this.formatCustomFields(this.customFields, this.custom_fields),
|
||||
};
|
||||
this.$axios
|
||||
.put(`/clients/sites/${this.site.id}/`, data)
|
||||
.then(r => {
|
||||
this.refreshDashboardTree();
|
||||
this.onOk();
|
||||
this.$q.loading.hide();
|
||||
this.notifySuccess(r.data);
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
getSite() {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.get(`/clients/sites/${this.site.id}/`)
|
||||
.then(r => {
|
||||
this.$q.loading.hide();
|
||||
this.localSite.name = r.data.name;
|
||||
this.localSite.client = r.data.client;
|
||||
|
||||
for (let field of this.customFields) {
|
||||
const value = r.data.custom_fields.find(value => value.field === field.id);
|
||||
|
||||
if (field.type === "multiple") {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.custom_fields[field.name] = [];
|
||||
} else if (field.type === "checkbox") {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.this.custom_fields[field.name] = false;
|
||||
} else {
|
||||
if (value) this.custom_fields[field.name] = value.value;
|
||||
else this.custom_fields[field.name] = "";
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
refreshDashboardTree() {
|
||||
this.$store.dispatch("loadTree");
|
||||
this.$store.dispatch("getUpdatedSites");
|
||||
},
|
||||
getClients() {
|
||||
this.$axios
|
||||
.get("/clients/")
|
||||
.then(r => {
|
||||
r.data.forEach(client => {
|
||||
this.clientOptions.push({ label: client.name, value: client.id });
|
||||
});
|
||||
})
|
||||
.catch(e => {});
|
||||
},
|
||||
show() {
|
||||
this.$refs.dialog.show();
|
||||
},
|
||||
hide() {
|
||||
this.$refs.dialog.hide();
|
||||
},
|
||||
onHide() {
|
||||
this.$emit("hide");
|
||||
},
|
||||
onOk() {
|
||||
this.$emit("ok");
|
||||
this.hide();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getClients();
|
||||
|
||||
// Get custom fields
|
||||
this.getCustomFields("site").then(r => {
|
||||
this.customFields = r.data.filter(field => !field.hide_in_ui);
|
||||
});
|
||||
|
||||
// Copy site prop locally
|
||||
if (this.editing) {
|
||||
this.getSite();
|
||||
} else {
|
||||
if (this.client) this.localSite.client = this.client;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -1,155 +0,0 @@
|
||||
<template>
|
||||
<q-dialog ref="dialog" @hide="onHide">
|
||||
<div class="q-dialog-plugin" style="width: 60vw; max-width: 60vw">
|
||||
<q-card>
|
||||
<q-bar>
|
||||
<q-btn @click="getSites" class="q-mr-sm" dense flat push icon="refresh" />Sites for {{ client.name }}
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
</q-btn>
|
||||
</q-bar>
|
||||
<div class="q-pa-sm" style="min-height: 40vh; max-height: 40vh">
|
||||
<div class="q-gutter-sm">
|
||||
<q-btn label="New" dense flat push unelevated no-caps icon="add" @click="showAddSite" />
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
:rows="sites"
|
||||
:columns="columns"
|
||||
v-model:pagination="pagination"
|
||||
row-key="id"
|
||||
binary-state-sort
|
||||
hide-pagination
|
||||
virtual-scroll
|
||||
:rows-per-page-options="[0]"
|
||||
no-data-label="No Sites"
|
||||
>
|
||||
<!-- body slots -->
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props" class="cursor-pointer" @dblclick="showEditSite(props.row)">
|
||||
<!-- context menu -->
|
||||
<q-menu context-menu>
|
||||
<q-list dense style="min-width: 200px">
|
||||
<q-item clickable v-close-popup @click="showEditSite(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="edit" />
|
||||
</q-item-section>
|
||||
<q-item-section>Edit</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showSiteDeleteModal(props.row)">
|
||||
<q-item-section side>
|
||||
<q-icon name="delete" />
|
||||
</q-item-section>
|
||||
<q-item-section>Delete</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-item clickable v-close-popup>
|
||||
<q-item-section>Close</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
<!-- name -->
|
||||
<q-td>
|
||||
{{ props.row.name }}
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-card>
|
||||
</div>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import mixins from "@/mixins/mixins";
|
||||
import SitesForm from "@/components/modals/clients/SitesForm";
|
||||
import DeleteClient from "@/components/modals/clients/DeleteClient";
|
||||
|
||||
export default {
|
||||
name: "SitesTable",
|
||||
emits: ["hide", "ok", "cancel"],
|
||||
mixins: [mixins],
|
||||
props: {
|
||||
client: !Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sites: [],
|
||||
columns: [{ name: "name", label: "Name", field: "name", align: "left" }],
|
||||
pagination: {
|
||||
rowsPerPage: 0,
|
||||
sortBy: "name",
|
||||
descending: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getSites() {
|
||||
this.$q.loading.show();
|
||||
this.$axios
|
||||
.get(`clients/${this.client.id}/`)
|
||||
.then(r => {
|
||||
this.sites = r.data.sites;
|
||||
this.$q.loading.hide();
|
||||
})
|
||||
.catch(e => {
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
},
|
||||
showSiteDeleteModal(site) {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: site,
|
||||
type: "site",
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getSites();
|
||||
});
|
||||
},
|
||||
showEditSite(site) {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
site: site,
|
||||
client: site.client,
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getSites();
|
||||
});
|
||||
},
|
||||
showAddSite() {
|
||||
this.$q
|
||||
.dialog({
|
||||
component: SitesForm,
|
||||
componentProps: {
|
||||
client: this.client.id,
|
||||
},
|
||||
})
|
||||
.onOk(() => {
|
||||
this.getSites();
|
||||
});
|
||||
},
|
||||
show() {
|
||||
this.$refs.dialog.show();
|
||||
},
|
||||
hide() {
|
||||
this.$refs.dialog.hide();
|
||||
},
|
||||
onHide() {
|
||||
this.$emit("hide");
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getSites();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -154,15 +154,11 @@ export default function () {
|
||||
let siteNode = {
|
||||
label: site.name,
|
||||
id: site.id,
|
||||
raw: `Site | ${site.id} `,
|
||||
raw: `Site|${site.id}`,
|
||||
header: "generic",
|
||||
icon: "apartment",
|
||||
client: client.id,
|
||||
selectable: true,
|
||||
server_policy: site.server_policy,
|
||||
workstation_policy: site.workstation_policy,
|
||||
alert_template: site.alert_template,
|
||||
blockInheritance: site.block_policy_inheritance
|
||||
site: site
|
||||
}
|
||||
|
||||
if (site.maintenance_mode) { siteNode["color"] = "green" }
|
||||
@@ -175,14 +171,11 @@ export default function () {
|
||||
let clientNode = {
|
||||
label: client.name,
|
||||
id: client.id,
|
||||
raw: `Client | ${client.id} `,
|
||||
raw: `Client|${client.id}`,
|
||||
header: "root",
|
||||
icon: "business",
|
||||
server_policy: client.server_policy,
|
||||
workstation_policy: client.workstation_policy,
|
||||
alert_template: client.alert_template,
|
||||
blockInheritance: client.block_policy_inheritance,
|
||||
children: childSites
|
||||
children: childSites,
|
||||
client: client
|
||||
}
|
||||
|
||||
if (client.maintenance_mode) clientNode["color"] = "green"
|
||||
|
||||
@@ -144,6 +144,21 @@ export function formatCheckOptions(data, flat = false) {
|
||||
}
|
||||
|
||||
|
||||
export function formatCustomFields(fields, values) {
|
||||
let tempArray = [];
|
||||
|
||||
for (let field of fields) {
|
||||
if (field.type === "multiple") {
|
||||
tempArray.push({ multiple_value: values[field.name], field: field.id });
|
||||
} else if (field.type === "checkbox") {
|
||||
tempArray.push({ bool_value: values[field.name], field: field.id });
|
||||
} else {
|
||||
tempArray.push({ string_value: values[field.name], field: field.id });
|
||||
}
|
||||
}
|
||||
return tempArray
|
||||
}
|
||||
|
||||
// date formatting
|
||||
|
||||
export function formatDate(dateString) {
|
||||
|
||||
@@ -416,13 +416,15 @@ import AgentTable from "@/components/AgentTable";
|
||||
import SubTableTabs from "@/components/SubTableTabs";
|
||||
import AlertsIcon from "@/components/AlertsIcon";
|
||||
import PolicyAdd from "@/components/automation/modals/PolicyAdd";
|
||||
import ClientsForm from "@/components/modals/clients/ClientsForm";
|
||||
import SitesForm from "@/components/modals/clients/SitesForm";
|
||||
import DeleteClient from "@/components/modals/clients/DeleteClient";
|
||||
import ClientsForm from "@/components/clients/ClientsForm";
|
||||
import SitesForm from "@/components/clients/SitesForm";
|
||||
import DeleteClient from "@/components/clients/DeleteClient";
|
||||
import InstallAgent from "@/components/modals/agents/InstallAgent";
|
||||
import UserPreferences from "@/components/modals/coresettings/UserPreferences";
|
||||
import AlertTemplateAdd from "@/components/modals/alerts/AlertTemplateAdd";
|
||||
|
||||
import { removeClient, removeSite } from "@/api/clients";
|
||||
|
||||
export default {
|
||||
name: "Dashboard",
|
||||
emits: [],
|
||||
@@ -722,13 +724,34 @@ export default {
|
||||
});
|
||||
},
|
||||
showDeleteModal(node) {
|
||||
this.$q.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: node.children ? node.client : node.site,
|
||||
type: node.children ? "client" : "site",
|
||||
},
|
||||
});
|
||||
if ((node.children && node.client.agent_count > 0) || (!node.children && node.site.agent_count > 0)) {
|
||||
this.$q.dialog({
|
||||
component: DeleteClient,
|
||||
componentProps: {
|
||||
object: node.children ? node.client : node.site,
|
||||
type: node.children ? "client" : "site",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.$q
|
||||
.dialog({
|
||||
title: "Are you sure?",
|
||||
message: `Delete site: ${node.label}.`,
|
||||
cancel: true,
|
||||
ok: { label: "Delete", color: "negative" },
|
||||
})
|
||||
.onOk(async () => {
|
||||
this.$q.loading.show();
|
||||
try {
|
||||
const result = node.children ? await removeClient(node.id) : await removeSite(node.id);
|
||||
this.notifySuccess(result);
|
||||
this.$store.dispatch("loadTree");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.$q.loading.hide();
|
||||
});
|
||||
}
|
||||
},
|
||||
showInstallAgent(node) {
|
||||
this.sitePk = node.id;
|
||||
|
||||
Reference in New Issue
Block a user