Améliorer l'affichage de la hiérarchie des motifs et ajouter une checkbox « Afficher uniquement les commentaires ».

This commit is contained in:
Boris Waaub
2025-11-06 10:10:06 +00:00
committed by Julien Fastré
parent 305c6deb24
commit b31778c068
18 changed files with 222 additions and 132 deletions

View File

@@ -175,8 +175,6 @@ async function download_and_decrypt_doc(
throw new Error("no version associated to stored object"); throw new Error("no version associated to stored object");
} }
// sometimes, the downloadInfo may be embedded into the storedObject
console.log("storedObject", storedObject);
let downloadInfo; let downloadInfo;
if ( if (
typeof storedObject._links !== "undefined" && typeof storedObject._links !== "undefined" &&

View File

@@ -16,7 +16,9 @@ use Chill\TicketBundle\Messenger\PostTicketUpdateMessage;
use Chill\TicketBundle\Repository\TicketRepositoryInterface; use Chill\TicketBundle\Repository\TicketRepositoryInterface;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class PostTicketUpdateMessageHandler final readonly class PostTicketUpdateMessageHandler
{ {
public function __construct( public function __construct(

View File

@@ -13,6 +13,7 @@ interface MotiveBase {
id: number; id: number;
active: boolean; active: boolean;
label: TranslatableString; label: TranslatableString;
makeTicketEmergency: TicketEmergencyState;
} }
/** /**

View File

@@ -1,7 +1,24 @@
<template> <template>
<banner-component :ticket="ticket" /> <banner-component :ticket="ticket" />
<div class="container-xxl pt-1" style="padding-bottom: 55px" v-if="!loading"> <div class="container-xxl pt-1" style="padding-bottom: 55px" v-if="!loading">
<previous-tickets-component :key="refreshKey" /> <div class="row">
<div class="col">
<div class="form-check">
<input
v-model="showOnlyHistoryComments"
class="form-check-input"
type="checkbox"
id="showOnlyCommentsCheckbox"
/>
<label class="form-check-label" for="showOnlyCommentsCheckbox">
{{ trans(CHILL_TICKET_LIST_SHOW_ONLY_HISTORY_COMMENTS) }}
</label>
</div>
</div>
<div class="col d-flex justify-content-end">
<previous-tickets-component :key="refreshKey" />
</div>
</div>
<ticket-history-list-component <ticket-history-list-component
:history="ticketHistory" :history="ticketHistory"
:key="ticketHistory.length" :key="ticketHistory.length"
@@ -64,15 +81,20 @@ import {
CHILL_TICKET_TICKET_INIT_FORM_ERROR, CHILL_TICKET_TICKET_INIT_FORM_ERROR,
CHILL_TICKET_TICKET_INIT_FORM_WARNING, CHILL_TICKET_TICKET_INIT_FORM_WARNING,
CHILL_TICKET_LIST_LOADING_TICKET_DETAILS, CHILL_TICKET_LIST_LOADING_TICKET_DETAILS,
CHILL_TICKET_LIST_SHOW_ONLY_HISTORY_COMMENTS,
} from "translator"; } from "translator";
const store = useStore(); const store = useStore();
const toast = useToast(); const toast = useToast();
store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket); store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
const showOnlyHistoryComments = ref(false);
const ticket = computed(() => store.getters.getTicket as Ticket); const ticket = computed(() => store.getters.getTicket as Ticket);
const ticketHistory = computed(() => store.getters.getTicketHistory); const ticketHistory = computed(() =>
showOnlyHistoryComments.value
? store.getters.getTicketHistoryComments
: store.getters.getTicketHistory,
);
const motives = computed(() => store.getters.getMotives as Motive[]); const motives = computed(() => store.getters.getMotives as Motive[]);
const suggestedPersons = computed(() => store.getters.getPersons as Person[]); const suggestedPersons = computed(() => store.getters.getPersons as Person[]);
const showTicketInitFormModal = ref(false); const showTicketInitFormModal = ref(false);
@@ -81,21 +103,22 @@ const refreshKey = ref(0);
async function handleFormSubmit(ticketForm: TicketInitForm) { async function handleFormSubmit(ticketForm: TicketInitForm) {
try { try {
if (!ticketForm.motive || ticketForm.content.trim() === "") {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
}
if (ticketForm.motive) { if (ticketForm.motive) {
await store.dispatch("createMotive", ticketForm.motive); await store.dispatch("createMotive", ticketForm.motive);
} else {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
} }
if (ticketForm.content.trim() !== "") {
if (ticketForm.content && ticketForm.content.trim() !== "") {
await store.dispatch("createComment", ticketForm.content); await store.dispatch("createComment", ticketForm.content);
} else {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
} }
await store.dispatch("setEmergency", ticketForm.emergency); // Ne pas mettre à jour emergency si le motif force l'état d'urgence
if (ticketForm.motive && !ticketForm.motive.makeTicketEmergency) {
await store.dispatch("setEmergency", ticketForm.emergency);
}
await store.dispatch("setAddressees", ticketForm.addressees); await store.dispatch("setAddressees", ticketForm.addressees);
await store.dispatch("setPersons", ticketForm.persons); await store.dispatch("setPersons", ticketForm.persons);

View File

@@ -90,7 +90,7 @@ function addNewEntity({ entity }: { entity: EntitiesOrMe }) {
function removeEntity({ entity }: { entity: EntitiesOrMe }) { function removeEntity({ entity }: { entity: EntitiesOrMe }) {
const index = selectedEntities.value.findIndex( const index = selectedEntities.value.findIndex(
(selectedEntity) => selectedEntity === entity, (selectedEntity: Entities) => selectedEntity === entity,
); );
if (index !== -1) { if (index !== -1) {
selectedEntities.value.splice(index, 1); selectedEntities.value.splice(index, 1);

View File

@@ -3,6 +3,9 @@
<div class="container-xxl text-primary"> <div class="container-xxl text-primary">
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0"> <div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<div class="small text-muted">
{{ motiveHierarchyLabel(ticket.currentMotive) }}
</div>
<h1> <h1>
{{ getTicketTitle(ticket) }} {{ getTicketTitle(ticket) }}
<peloton-component <peloton-component
@@ -26,7 +29,7 @@
<state-toggle-component v-model="isOpen" /> <state-toggle-component v-model="isOpen" />
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<p class="created-at-timespan" v-if="ticket.createdAt"> <p class="created-at-timespan" v-if="since">
{{ {{
trans(CHILL_TICKET_TICKET_BANNER_SINCE, { trans(CHILL_TICKET_TICKET_BANNER_SINCE, {
time: since, time: since,
@@ -127,7 +130,7 @@ import {
import { useStore } from "vuex"; import { useStore } from "vuex";
// Utils // Utils
import { getTicketTitle } from "../utils/utils"; import { getTicketTitle, motiveHierarchyLabel } from "../utils/utils";
const props = defineProps<{ const props = defineProps<{
ticket: Ticket; ticket: Ticket;

View File

@@ -1,6 +1,10 @@
<template> <template>
<div class="col-12 fw-bolder"> <div class="col-12">
{{ motiveLabelRecursive(props.motiveHistory.motive) }} <span class="badge-motive">
<div>
{{ localizeString(props.motiveHistory.motive.label) }}
</div>
</span>
<peloton-component <peloton-component
:stored-objects="motive ? motive.storedObjects : null" :stored-objects="motive ? motive.storedObjects : null"
pelotonBtnClass="float-end" pelotonBtnClass="float-end"
@@ -9,17 +13,17 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ComputedRef } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
// Types
import {Motive, MotiveHistory, MotiveWithParent} from "../../../../types";
//Utils //utils
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
// Types
import { Motive, MotiveHistory } from "../../../../types";
//Components //Components
import PelotonComponent from "../PelotonComponent.vue"; import PelotonComponent from "../PelotonComponent.vue";
import { computed, ComputedRef } from "vue";
import {motiveLabelRecursive} from "../../utils/utils";
const props = defineProps<{ motiveHistory: MotiveHistory }>(); const props = defineProps<{ motiveHistory: MotiveHistory }>();
@@ -29,4 +33,18 @@ const motive: ComputedRef<Motive | null> = computed(() =>
); );
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
span.badge-motive {
margin: 0.2rem 0.1rem;
display: inline-block;
padding: 0 0.5em !important;
background-color: #fff;
color: #2c2d2f;
border: 1px solid #dee2e6;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-width: 2px;
border-bottom-style: solid;
border-radius: 6px;
}
</style>

View File

@@ -11,11 +11,14 @@
:open-direction="openDirection" :open-direction="openDirection"
:multiple="false" :multiple="false"
:searchable="true" :searchable="true"
:internalSearch="false"
:placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)" :placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)"
:options="flattenedMotives" :options="options"
:loading="isLoading"
v-model="motive" v-model="motive"
class="form-control" class="form-control"
@remove="(value: Motive) => $emit('remove', value)" @remove="(value: Motive) => $emit('remove', value)"
@search-change="search"
:disabled="disabled" :disabled="disabled"
> >
<template <template
@@ -57,7 +60,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed, ref, watch } from "vue";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
// Types // Types
@@ -116,50 +119,73 @@ const motive = computed<Motive | null>({
emit("update:modelValue", value); emit("update:modelValue", value);
}, },
}); });
type MotiveOptions = Motive & {
isChild?: boolean;
isParent?: boolean;
level?: number;
displayLabel: string;
breadcrumb: string[];
};
const flattenedMotives = computed(() => { const motiveOptions: MotiveOptions[] = [];
const result: (Motive & { const options = ref<MotiveOptions[]>([]);
isChild?: boolean; const isLoading = ref<boolean>(false);
isParent?: boolean;
level?: number;
displayLabel: string;
breadcrumb: string[];
})[] = [];
const processMotiveRecursively = ( function search(query: string) {
motive: Motive, isLoading.value = true;
isChild = false, options.value = motiveOptions.filter((m) =>
level = 0, m.breadcrumb.some((crumb) =>
parentBreadcrumb: string[] = [], crumb.toLowerCase().includes(query.trim().toLowerCase()),
) => { ),
const hasChildren = motive.children && motive.children.length > 0; );
const displayLabel = localizeString(motive.label); isLoading.value = false;
const breadcrumb = [...parentBreadcrumb, displayLabel]; }
if (props.allowParentSelection || !hasChildren) { function processMotiveRecursively(
result.push({ motive: Motive,
...motive, isChild = false,
isChild, level = 0,
isParent: hasChildren, parentBreadcrumb: string[] = [],
level, ) {
breadcrumb, const hasChildren = motive.children && motive.children.length > 0;
displayLabel, const displayLabel = localizeString(motive.label);
}); const breadcrumb = [...parentBreadcrumb, displayLabel];
}
if (hasChildren) { if (props.allowParentSelection || !hasChildren) {
motive.children.forEach((childMotive) => { const optionValue = {
processMotiveRecursively(childMotive, true, level + 1, breadcrumb); ...motive,
}); isChild,
} isParent: hasChildren,
}; level,
breadcrumb,
displayLabel,
};
motiveOptions.push(optionValue);
options.value.push(optionValue);
}
if (hasChildren) {
motive.children.forEach((childMotive) => {
processMotiveRecursively(childMotive, true, level + 1, breadcrumb);
});
}
}
function fillOptions() {
motiveOptions.length = 0;
options.value.length = 0;
props.motives.forEach((parentMotive) => { props.motives.forEach((parentMotive) => {
processMotiveRecursively(parentMotive, false, 0, []); processMotiveRecursively(parentMotive, false, 0, []);
}); });
}
return result; watch(
}); () => props.motives,
() => {
fillOptions();
},
{ deep: true },
);
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -112,7 +112,7 @@ function addNewEntity({ entity }: { entity: EntitiesOrMe }) {
function removeEntity({ entity }: { entity: EntitiesOrMe }) { function removeEntity({ entity }: { entity: EntitiesOrMe }) {
if (multiple) { if (multiple) {
const index = selectedEntities.value.findIndex( const index = selectedEntities.value.findIndex(
(selectedEntity) => selectedEntity === entity, (selectedEntity: Entities) => selectedEntity === entity,
); );
if (index !== -1) { if (index !== -1) {
selectedEntities.value.splice(index, 1); selectedEntities.value.splice(index, 1);

View File

@@ -1,52 +1,48 @@
<template> <template>
<div class="d-flex justify-content-end"> <div @click="handleClick">
<div @click="handleClick"> <button type="button" class="btn btn-light position-relative">
<button type="button" class="btn btn-light position-relative"> {{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }}
{{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }}
<span <span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green"
> >
{{ previousTickets.length }} {{ previousTickets.length }}
<span class="visually-hidden">Tickets</span> <span class="visually-hidden">Tickets</span>
</span> </span>
</button> </button>
</div>
<!-- Modal for ticket list -->
<Modal
v-if="showPreviousTicketModal"
:show="showPreviousTicketModal"
modal-dialog-class="modal-lg"
@close="closeModal"
>
<template #header>
<h3 class="modal-title">
{{
trans(CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS, {
name:
currentPersons.length > 0
? currentPersons
.map((person: Person) => person.text)
.join(", ")
: undefined,
})
}}
</h3>
</template>
<template #body>
<ticket-list-component
:hasMoreTickets="pagination.next !== null"
:tickets="previousTickets"
:title="trans(CHILL_TICKET_LIST_NO_TICKETS)"
@fetchNextPage="fetchNextPage"
@view-ticket="handleViewTicket"
@edit-ticket="handleEditTicket"
/>
</template>
</Modal>
</div> </div>
<!-- Modal for ticket list -->
<Modal
v-if="showPreviousTicketModal"
:show="showPreviousTicketModal"
modal-dialog-class="modal-lg"
@close="closeModal"
>
<template #header>
<h3 class="modal-title">
{{
trans(CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS, {
name:
currentPersons.length > 0
? currentPersons.map((person: Person) => person.text).join(", ")
: undefined,
})
}}
</h3>
</template>
<template #body>
<ticket-list-component
:hasMoreTickets="pagination.next !== null"
:tickets="previousTickets"
:title="trans(CHILL_TICKET_LIST_NO_TICKETS)"
@fetchNextPage="fetchNextPage"
@view-ticket="handleViewTicket"
@edit-ticket="handleEditTicket"
/>
</template>
</Modal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -20,7 +20,7 @@ export interface RootState {
user: UserState; user: UserState;
} }
export const store = createStore<RootState>({ export const store = createStore({
modules: { modules: {
motive: moduleMotive, motive: moduleMotive,
ticket: moduleTicket, ticket: moduleTicket,

View File

@@ -3,8 +3,8 @@ import {
makeFetch, makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; } from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex"; import type { RootState } from "..";
import { RootState } from ".."; import type { Module } from "vuex";
import { import {
ApiException, ApiException,

View File

@@ -62,6 +62,11 @@ export const moduleTicket: Module<State, RootState> = {
getTicketHistory: (state) => { getTicketHistory: (state) => {
return state.ticket.history; return state.ticket.history;
}, },
getTicketHistoryComments: (state) => {
return state.ticket.history.filter(
(history) => history.event_type == "add_comment",
);
},
getCurrentPersons(state) { getCurrentPersons(state) {
return state.ticket.currentPersons; return state.ticket.currentPersons;
}, },

View File

@@ -8,7 +8,7 @@ import {
CHILL_TICKET_TICKET_BANNER_AND, CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE, CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
} from "translator"; } from "translator";
import {MotiveWithParent, Ticket, TicketSimple} from "../../../types"; import { MotiveWithParent, Ticket, TicketSimple } from "../../../types";
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
/** /**
@@ -18,8 +18,11 @@ import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizat
* @returns Une chaîne formatée représentant le temps écoulé * @returns Une chaîne formatée représentant le temps écoulé
*/ */
export function getSinceCreated(createdAt: string, currentTime: Date): string { export function getSinceCreated(createdAt: string, currentTime: Date): string {
if (!createdAt) {
return "";
}
const date = ISOToDatetime(createdAt); const date = ISOToDatetime(createdAt);
if (date == null) { if (!date) {
return ""; return "";
} }
@@ -29,7 +32,6 @@ export function getSinceCreated(createdAt: string, currentTime: Date): string {
const minutesDiff = Math.floor((timeDiff % (1000 * 3600)) / (1000 * 60)); const minutesDiff = Math.floor((timeDiff % (1000 * 3600)) / (1000 * 60));
const secondsDiff = Math.floor((timeDiff % (1000 * 60)) / 1000); const secondsDiff = Math.floor((timeDiff % (1000 * 60)) / 1000);
// On construit la liste des parties à afficher
const parts: string[] = []; const parts: string[] = [];
if (daysDiff > 0) { if (daysDiff > 0) {
parts.push(trans(CHILL_TICKET_TICKET_BANNER_DAYS, { count: daysDiff })); parts.push(trans(CHILL_TICKET_TICKET_BANNER_DAYS, { count: daysDiff }));
@@ -83,18 +85,19 @@ export function getTicketTitle(ticket: Ticket | TicketSimple): string {
return `#${ticket.id} ${trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE)}`; return `#${ticket.id} ${trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE)}`;
} }
export function motiveLabelRecursive(motive: MotiveWithParent|null): string { export function motiveHierarchyLabel(motive: MotiveWithParent | null): string {
console.log('test', motive);
if (null === motive) { if (null === motive) {
return ""; return "Aucun motif";
} }
const str = []; const str = [];
let m: MotiveWithParent|null = motive; let m: MotiveWithParent | null = motive;
do { do {
str.push(localizeString(m.label)); str.push(localizeString(m.label));
m = m.parent; m = m.parent;
} while (m !== null); } while (m !== null);
str.reverse();
return str.reverse().join(" > "); if (str.length > 1) {
str.pop();
}
return str.join(" > ");
} }

View File

@@ -2,14 +2,11 @@
<div class="card mb-3 text-primary border-primary"> <div class="card mb-3 text-primary border-primary">
<div class="card-body"> <div class="card-body">
<div class="wrap-header"> <div class="wrap-header">
<div class="wh-row d-flex justify-content-between align-items-center"> <div class="wh-row d-flex justify-content-between align-items-top">
<div class="wh-col"> <div v-if="null !== ticket.currentMotive && null !== ticket.currentMotive.parent" class="wh-col">
<span <div class="small text-muted">
class="h2" {{ motiveHierarchyLabel(ticket.currentMotive) }}
style="color: var(--bs-chill-blue); font-variant: all-small-caps" </div>
>
{{ getTicketTitle(ticket) }}
</span>
</div> </div>
<div class="wh-col"> <div class="wh-col">
<emergency-component <emergency-component
@@ -23,9 +20,17 @@
</div> </div>
</div> </div>
<div class="wh-row"> <div class="wh-row">
<div class="wh-col" v-if="ticket.createdAt"> <div class="wh-col">
<span <span
v-if="ticket" class="h2"
style="color: var(--bs-chill-blue); font-variant: all-small-caps"
>
{{ getTicketTitle(ticket) }}
</span>
</div>
<div class="wh-col">
<span
v-if="ticket.createdAt"
:title="formatDateTime(ticket.createdAt.datetime, 'long', 'long')" :title="formatDateTime(ticket.createdAt.datetime, 'long', 'long')"
style="font-style: italic" style="font-style: italic"
> >
@@ -105,6 +110,7 @@ import {
getSinceCreated, getSinceCreated,
formatDateTime, formatDateTime,
getTicketTitle, getTicketTitle,
motiveHierarchyLabel,
} from "../../TicketApp/utils/utils"; } from "../../TicketApp/utils/utils";
// Components // Components

View File

@@ -4,6 +4,7 @@ chill_ticket:
title: "Tickets" title: "Tickets"
title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}" title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}"
title_menu: "Tickets de l'usager" title_menu: "Tickets de l'usager"
show_only_history_comments: "Afficher uniquement les commentaires"
title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}" title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}"
no_tickets: "Aucun ticket" no_tickets: "Aucun ticket"
loading_ticket: "Chargement des tickets..." loading_ticket: "Chargement des tickets..."

3
src/shims-custom.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "vue-multiselect";
declare module "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
declare module "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";

View File

@@ -11,4 +11,9 @@
"ChillPersonAssets/*": ["./src/Bundle/ChillPersonBundle/Resources/public/*"] "ChillPersonAssets/*": ["./src/Bundle/ChillPersonBundle/Resources/public/*"]
} }
}, },
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
]
} }