Misc: homepage widget with tickets, and improvements in ticket list

This commit is contained in:
Boris Waaub
2025-09-16 11:16:57 +00:00
committed by Julien Fastré
parent e87429933a
commit 0ba2cbc1e8
33 changed files with 1200 additions and 838 deletions

View File

@@ -165,6 +165,7 @@ export interface TicketFilters {
byCreatedBefore: string;
byResponseTimeExceeded: boolean;
byAddresseeToMe: boolean;
byTicketId: number | null;
}
export interface TicketFilterParams {
@@ -179,6 +180,7 @@ export interface TicketFilterParams {
byCreatedBefore?: string;
byResponseTimeExceeded?: string;
byAddresseeToMe?: boolean;
byTicketId?: number;
}
export interface TicketInitForm {

View File

@@ -1,20 +1,11 @@
<template>
<div class="col-12">
<div class="col-12" v-if="!commentHistory.deleted">
<blockquote class="chill-user-quote">
<button
class="btn btn-sm btn-primary float-end"
title="Visible"
@click="visibleComment"
v-if="commentHistory.deleted"
>
<i class="bi bi-eye"></i>
</button>
<button
class="btn btn-sm bg-chill-red float-end text-white"
@click="maskComment"
v-else
@click="deleteComment"
>
<i class="bi bi-eye-slash"></i>
<i class="bi bi-trash"></i>
</button>
<button
class="btn btn-sm btn-edit mx-2 float-end text-white"
@@ -24,14 +15,16 @@
/>
<p v-html="convertMarkdownToHtml(commentHistory.content)"></p>
<span
v-if="commentHistory.deleted"
class="ms-2 d-block text-center fst-italic text-muted"
>
{{ trans(CHILL_TICKET_TICKET_MASK_COMMENT_HINT) }}
</span>
</blockquote>
</div>
<div class="col-12" v-else>
<span class="ms-2 d-block text-center">
{{ trans(CHILL_TICKET_TICKET_MASK_COMMENT_HINT) }}
<button class="btn btn-primary btn-sm ms-2" @click="restoreComment">
{{ trans(CANCEL) }}
</button>
</span>
</div>
<Modal
v-if="editCommentModal"
:show="editCommentModal"
@@ -75,6 +68,7 @@ import {
CHILL_TICKET_TICKET_MASK_COMMENT_SUCCESS,
CHILL_TICKET_TICKET_MASK_COMMENT_HINT,
CHILL_TICKET_TICKET_VISIBLE_COMMENT_SUCCESS,
CANCEL,
} from "translator";
import { useToast } from "vue-toast-notification";
@@ -97,18 +91,17 @@ const saveComment = () => {
toast.success(trans(CHILL_TICKET_TICKET_EDIT_COMMENT_SUCCESS));
};
const maskComment = () => {
store.dispatch("maskComment", props.commentHistory.id);
const deleteComment = () => {
store.dispatch("deleteComment", props.commentHistory.id);
store.commit("addRemovedCommentIds", props.commentHistory.id);
editCommentModal.value = false;
toast.success(trans(CHILL_TICKET_TICKET_MASK_COMMENT_SUCCESS));
};
const visibleComment = () => {
store.dispatch("visibleComment", props.commentHistory.id);
const restoreComment = () => {
store.dispatch("restoreComment", props.commentHistory.id);
editCommentModal.value = false;
toast.success(trans(CHILL_TICKET_TICKET_VISIBLE_COMMENT_SUCCESS));
};
const preprocess = (markdown: string): string => {
return markdown;
};

View File

@@ -0,0 +1,49 @@
<template>
<div class="input-group mb-3">
<input
v-model="ticketId"
type="number"
class="form-control"
:placeholder="trans(CHILL_TICKET_LIST_FILTER_TICKET_ID)"
@input="
ticketId = isNaN(Number(($event.target as HTMLInputElement).value))
? null
: Number(($event.target as HTMLInputElement).value)
"
/>
<span class="input-group-text" v-if="ticketId !== null">
<i
class="fa fa-times chill-red"
style="cursor: pointer"
@click="ticketId = null"
title="clear"
></i>
</span>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
// Translation
import { trans, CHILL_TICKET_LIST_FILTER_TICKET_ID } from "translator";
const props = defineProps<{
modelValue: number | null;
}>();
const ticketId = ref<number | null>(props.modelValue);
watch(
() => props.modelValue,
(newVal) => {
if (newVal === null) {
ticketId.value = null;
}
},
);
const emit = defineEmits(["update:modelValue"]);
watch(ticketId, (ticketId) => {
emit("update:modelValue", ticketId ? ticketId : null);
});
</script>

View File

@@ -8,23 +8,15 @@ import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/
export interface State {
comments: Comment[];
removedCommentIds: Comment["id"][];
}
export const moduleComment: Module<State, RootState> = {
state: () => ({
comments: [] as Comment[],
removedCommentIds: [] as Comment["id"][],
}),
getters: {
canBeDisplayed:
(state: State, getters: unknown, rootState: RootState) =>
(comment: Comment) => {
return (
(comment.deleted &&
comment.createdBy?.username ===
rootState.user.currentUser?.username) ||
!comment.deleted
);
},
canBeEdited:
(state: State, getters: unknown, rootState: RootState) =>
(comment: Comment) => {
@@ -32,8 +24,15 @@ export const moduleComment: Module<State, RootState> = {
comment.createdBy?.username === rootState.user.currentUser?.username
);
},
getRemovedCommentIds(state) {
return state.removedCommentIds;
},
},
mutations: {
addRemovedCommentIds(state, commentId: number) {
state.removedCommentIds.push(commentId);
},
},
mutations: {},
actions: {
async createComment({ commit, rootState }, content: Comment["content"]) {
try {
@@ -64,7 +63,7 @@ export const moduleComment: Module<State, RootState> = {
throw error.name;
}
},
async maskComment({ commit }, id: Comment["id"]) {
async deleteComment({ commit }, id: Comment["id"]) {
try {
const result: Comment = await makeFetch(
"POST",
@@ -77,7 +76,7 @@ export const moduleComment: Module<State, RootState> = {
}
},
async visibleComment({ commit }, id: Comment["id"]) {
async restoreComment({ commit }, id: Comment["id"]) {
try {
const result: Comment = await makeFetch(
"POST",

View File

@@ -73,7 +73,10 @@ export const moduleTicketList: Module<State, RootState> = {
([, value]) =>
value !== undefined &&
value !== null &&
(value === true || (value !== "" && value.length > 0)),
(value === true ||
(typeof value === "number" && !isNaN(value)) ||
(typeof value === "string" && value !== "") ||
(Array.isArray(value) && value.length > 0)),
),
);
params = new URLSearchParams(

View File

@@ -101,7 +101,6 @@ onMounted(async () => {
await store.dispatch("getCurrentUser");
await store.dispatch("fetchTicketList", filters);
await store.dispatch("fetchMotives");
await store.dispatch("fetchUserGroups");
} finally {
isLoading.value = false;
}

View File

@@ -0,0 +1,65 @@
<template>
<label :for="dateId" class="form-label col-12">
{{ label }}
</label>
<div class="d-flex gap-2">
<div class="input-group">
<input
type="date"
:id="dateId"
v-model="modelDate"
class="form-control"
:disabled="disabled"
/>
<input
type="time"
v-model="modelTime"
class="form-control"
:disabled="disabled"
style="max-width: 120px"
placeholder="hh:mm"
/>
<span class="input-group-text" v-if="modelDate">
<i
class="fa fa-times chill-red"
style="cursor: pointer"
@click="clear"
title="clear"
></i>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
label: string;
dateId: string;
modelValueDate: string;
modelValueTime: string;
defaultValueTime: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValueDate", value: string): void;
(e: "update:modelValueTime", value: string): void;
}>();
const modelDate = computed({
get: () => props.modelValueDate,
set: (val: string) => emit("update:modelValueDate", val),
});
const modelTime = computed({
get: () => props.modelValueTime,
set: (val: string) => emit("update:modelValueTime", val),
});
function clear() {
emit("update:modelValueDate", "");
emit("update:modelValueTime", props.defaultValueTime);
}
</script>

View File

@@ -43,12 +43,12 @@
}}</label>
<addressee-selector-component
v-model="selectedAddressees"
:suggested="userGroups"
:suggested="[]"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES)"
id="addresseeSelector"
/>
</div>
<div class="col-md-6">
<div class="col-md-6 mb-3">
<!-- Filtre par motifs -->
<div class="row">
<label class="form-label" for="motiveSelector">{{
@@ -60,7 +60,7 @@
id="motiveSelector"
/>
<div class="mt-1" style="min-height: 2.2em">
<div class="mb-2" style="min-height: 2em">
<div class="d-flex flex-wrap gap-2">
<span
v-for="motive in selectedMotives"
@@ -79,43 +79,40 @@
</div>
</div>
<!-- Filtre par état actuel -->
<div class="row">
<div class="col-6">
<div class="mb-2">
<label class="form-label pe-2" for="currentState">
{{ trans(CHILL_TICKET_LIST_FILTER_CURRENT_STATE) }}
</label>
<toggle-component
v-model="isClosedToggled"
:on-label="trans(CHILL_TICKET_LIST_FILTER_CLOSED)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_OPEN)"
:classColor="{
on: 'bg-chill-red',
off: 'bg-chill-green',
}"
@update:model-value="handleStateToggle"
id="currentState"
/>
</div>
<div class="d-flex gap-3">
<div>
<label class="form-label pe-2" for="currentState">
{{ trans(CHILL_TICKET_LIST_FILTER_CURRENT_STATE) }}
</label>
<toggle-component
v-model="isClosedToggled"
:on-label="trans(CHILL_TICKET_LIST_FILTER_CLOSED)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_OPEN)"
:classColor="{
on: 'bg-chill-red',
off: 'bg-chill-green',
}"
@update:model-value="handleStateToggle"
id="currentState"
/>
</div>
<!-- Filtre par état d'urgence -->
<div class="col-6">
<div class="mb-2">
<label class="form-label pe-2" for="emergency">
{{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }}
</label>
<toggle-component
v-model="isEmergencyToggled"
:on-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:classColor="{
on: 'bg-warning',
off: 'bg-secondary',
}"
@update:model-value="handleEmergencyToggle"
id="emergency"
/>
</div>
<div>
<label class="form-label pe-2" for="emergency">
{{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }}
</label>
<toggle-component
v-model="isEmergencyToggled"
:on-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:classColor="{
on: 'bg-warning',
off: 'bg-secondary',
}"
@update:model-value="handleEmergencyToggle"
id="emergency"
/>
</div>
</div>
</div>
@@ -124,6 +121,18 @@
<div class="row">
<!-- Filtre pour temps de réponse dépassé -->
<div class="col-md-6 mb-3">
<div class="form-check">
<input
v-model="filters.byAddresseeToMe"
class="form-check-input"
type="checkbox"
id="stateMe"
/>
<label class="form-check-label" for="stateMe">
{{ trans(CHILL_TICKET_LIST_FILTER_TO_ME) }}
</label>
</div>
<div class="form-check">
<input
class="form-check-input"
@@ -141,48 +150,40 @@
{{ trans(CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_WARNING) }}
</small>
</div>
<!-- Filtre par mes tickets -->
<!-- Filtre par numéro de ticket -->
<div class="col-md-6 mb-3">
<div class="d-flex gap-3">
<div class="form-check">
<input
v-model="filters.byAddresseeToMe"
class="form-check-input"
type="checkbox"
id="stateMe"
/>
<label class="form-check-label" for="stateMe">
{{ trans(CHILL_TICKET_LIST_FILTER_TO_ME) }}
</label>
</div>
</div>
<label class="form-label pe-2" for="ticketSelector">
{{ trans(CHILL_TICKET_LIST_FILTER_BY_TICKET_ID) }}
</label>
<ticket-selector v-model="filters.byTicketId" id="ticketSelector" />
</div>
</div>
<!-- Filtre par date de création -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="byCreatedAfter" class="form-label">{{
trans(CHILL_TICKET_LIST_FILTER_CREATED_AFTER)
}}</label>
<input
type="datetime-local"
id="byCreatedAfter"
v-model="filters.byCreatedAfter"
class="form-control"
<date-and-time-selector-component
:label="trans(CHILL_TICKET_LIST_FILTER_CREATED_AFTER)"
date-id="byCreatedAfter"
default-value-time="00:00"
:model-value-date="filters.byCreatedAfter"
:model-value-time="byCreatedAfterTime"
:disabled="filters.byResponseTimeExceeded"
@update:modelValueDate="filters.byCreatedAfter = $event"
@update:modelValueTime="byCreatedAfterTime = $event"
/>
</div>
<div class="col-md-6 mb-3">
<label for="byCreatedBefore" class="form-label">{{
trans(CHILL_TICKET_LIST_FILTER_CREATED_BEFORE)
}}</label>
<input
type="datetime-local"
id="byCreatedBefore"
v-model="filters.byCreatedBefore"
class="form-control"
<date-and-time-selector-component
:label="trans(CHILL_TICKET_LIST_FILTER_CREATED_BEFORE)"
date-id="byCreatedBefore"
default-value-time="23:59"
:model-value-date="filters.byCreatedBefore"
:model-value-time="byCreatedBeforeTime"
:disabled="filters.byResponseTimeExceeded"
@update:modelValueDate="filters.byCreatedBefore = $event"
@update:modelValueTime="byCreatedBeforeTime = $event"
/>
</div>
</div>
@@ -218,10 +219,13 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { useStore } from "vuex";
import type { Person } from "ChillPersonAssets/types";
import type { Motive, TicketFilterParams, TicketFilters } from "../../../types";
import { User, UserGroup, UserGroupOrUser } from "ChillMainAssets/types";
import {
type Motive,
type TicketFilterParams,
type TicketFilters,
} from "../../../types";
import { User, UserGroupOrUser } from "ChillMainAssets/types";
// Translation
import {
@@ -234,6 +238,7 @@ import {
CHILL_TICKET_LIST_FILTER_ADDRESSEES,
CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES,
CHILL_TICKET_LIST_FILTER_BY_MOTIVES,
CHILL_TICKET_LIST_FILTER_BY_TICKET_ID,
CHILL_TICKET_LIST_FILTER_REMOVE,
CHILL_TICKET_LIST_FILTER_OPEN,
CHILL_TICKET_LIST_FILTER_CLOSED,
@@ -254,6 +259,8 @@ import PersonsSelector from "../../TicketApp/components/Person/PersonsSelectorCo
import MotiveSelector from "../../TicketApp/components/Motive/MotiveSelectorComponent.vue";
import AddresseeSelectorComponent from "../../TicketApp/components/Addressee/AddresseeSelectorComponent.vue";
import ToggleComponent from "./ToggleComponent.vue";
import TicketSelector from "../../TicketApp/components/Ticket/TicketSelector.vue";
import DateAndTimeSelectorComponent from "./DateAndTimeSelectorComponent.vue";
// Props
const props = defineProps<{
@@ -267,8 +274,6 @@ const emit = defineEmits<{
"filters-changed": [filters: TicketFilterParams];
}>();
const store = useStore();
// État réactif
const filters = ref<TicketFilters>({
byCurrentState: ["open"],
@@ -277,15 +282,18 @@ const filters = ref<TicketFilters>({
byCreatedBefore: "",
byResponseTimeExceeded: false,
byAddresseeToMe: false,
byTicketId: null,
});
const byCreatedAfterTime = ref("00:00");
const byCreatedBeforeTime = ref("23:59");
// Sélection des personnes
const selectedPersons = ref<Person[]>([]);
const availablePersons = ref<Person[]>(props.availablePersons || []);
// Sélection des utilisateur assigné
const selectedAddressees = ref<UserGroupOrUser[]>([]);
const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]);
// Séléction des créateurs
const selectedCreator = ref<User[]>([]);
@@ -351,10 +359,10 @@ const handleEmergencyToggle = (value: boolean) => {
}
};
// Méthodes
const formatDateToISO = (dateString: string): string => {
if (!dateString) return dateString;
const formatDateToISO = (dateString: string, timeString: string): string => {
const [hours, minutes] = timeString.split(":").map(Number);
const date = new Date(dateString);
date.setHours(hours, minutes, 0, 0);
return date.toISOString();
};
@@ -406,11 +414,17 @@ const applyFilters = (): void => {
}
if (filters.value.byCreatedAfter) {
apiFilters.byCreatedAfter = formatDateToISO(filters.value.byCreatedAfter);
apiFilters.byCreatedAfter = formatDateToISO(
filters.value.byCreatedAfter,
byCreatedAfterTime.value,
);
}
if (filters.value.byCreatedBefore) {
apiFilters.byCreatedBefore = formatDateToISO(filters.value.byCreatedBefore);
apiFilters.byCreatedBefore = formatDateToISO(
filters.value.byCreatedBefore,
byCreatedBeforeTime.value,
);
}
if (filters.value.byResponseTimeExceeded) {
@@ -419,7 +433,12 @@ const applyFilters = (): void => {
if (filters.value.byAddresseeToMe) {
apiFilters.byAddresseeToMe = true;
}
if (filters.value.byAddresseeToMe) {
apiFilters.byAddresseeToMe = true;
}
if (filters.value.byTicketId) {
apiFilters.byTicketId = filters.value.byTicketId;
}
emit("filters-changed", apiFilters);
};
@@ -431,13 +450,14 @@ const resetFilters = (): void => {
byCreatedBefore: "",
byResponseTimeExceeded: false,
byAddresseeToMe: false,
byTicketId: null,
};
selectedPersons.value = [];
selectedCreator.value = [];
selectedAddressees.value = [];
selectedMotives.value = [];
selectedMotive.value = undefined;
isClosedToggled.value = true;
isClosedToggled.value = false;
isEmergencyToggled.value = false;
applyFilters();
};
@@ -446,7 +466,7 @@ const handleResponseTimeExceededChange = (): void => {
if (filters.value.byResponseTimeExceeded) {
filters.value.byCreatedBefore = "";
filters.value.byCreatedAfter = "";
isClosedToggled.value = true;
isClosedToggled.value = false;
}
};
</script>

View File

@@ -11,16 +11,6 @@
<div class="d-flex align-items-center fw-bold">
<i :class="`${actionIcons[history_line.event_type]} me-1`"></i>
<span>{{ explainSentence(history_line) }}</span>
<span
v-if="
history_line.event_type === 'add_comment' &&
history_line.data.deleted
"
class="badge bg-danger ms-2"
>
{{ trans(CHILL_TICKET_TICKET_HISTORY_MASK_COMMENT) }}
</span>
<state-component
:new_state="history_line.data.new_state"
v-if="history_line.event_type == 'state_change'"
@@ -59,10 +49,7 @@
/>
<comment-component
:commentHistory="history_line.data"
v-else-if="
history_line.event_type == 'add_comment' &&
canBeDisplayed(history_line.data)
"
v-else-if="history_line.event_type == 'add_comment'"
/>
<addressee-component
:addressees="history_line.data.addressees"
@@ -105,19 +92,24 @@ import {
CHILL_TICKET_TICKET_HISTORY_STATE_CHANGE,
CHILL_TICKET_TICKET_HISTORY_EMERGENCY_CHANGE,
CHILL_TICKET_TICKET_HISTORY_SET_CALLER,
CHILL_TICKET_TICKET_HISTORY_MASK_COMMENT,
} from "translator";
const props = defineProps<{ history?: TicketHistoryLine[] }>();
const history = props.history ?? [];
const store = useStore();
const actionIcons = ref(store.getters.getActionIcons);
const canBeDisplayed = ref(store.getters.canBeDisplayed);
const actionIcons = ref<Record<string, string>>(store.getters.getActionIcons);
const removedCommentIds = ref<number[]>(store.getters.getRemovedCommentIds);
const filteredHistoryLines = computed(() =>
history.filter(
(line: TicketHistoryLine) => !(line.event_type === "add_person"),
(line: TicketHistoryLine) =>
line.event_type !== "add_person" &&
!(
line.event_type == "add_comment" &&
line.data.deleted &&
!removedCommentIds.value.includes(line.data.id)
),
),
);

View File

@@ -24,6 +24,8 @@ chill_ticket:
addressees: "Par destinataire"
by_addressees: "Par destinataire"
by_motives: "Par motifs"
by_ticket_id: "Par numéro de ticket"
ticket_id: "Numéro de ticket"
current_state: "État actuel"
open: "Ouvert"
closed: "Clôturé"
@@ -55,7 +57,7 @@ chill_ticket:
create_ticket: "Ticket créé"
state_change: ""
emergency_change: ""
mask_comment: "Masqué"
mask_comment: "Supprimer"
previous_tickets: "Précédents tickets"
actions_toolbar:
cancel: "Annuler"
@@ -67,10 +69,10 @@ chill_ticket:
reopen_success: "Rouverture du ticket réussie"
reopen_error: "Erreur lors de la rouverture du ticket"
visible_comment:
success: "Commentaire visible"
success: "Commentaire restauré"
mask_comment:
success: "Commentaire masqué"
hint: "Ce commentaire est masqué; il n'est visible que par vous."
success: "Commentaire supprimé"
hint: "Ce commentaire a été supprimé."
edit_comment:
title: "Éditer le commentaire"
success: "Commentaire modifié"