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");
}
// sometimes, the downloadInfo may be embedded into the storedObject
console.log("storedObject", storedObject);
let downloadInfo;
if (
typeof storedObject._links !== "undefined" &&

View File

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

View File

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

View File

@@ -1,7 +1,24 @@
<template>
<banner-component :ticket="ticket" />
<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
:history="ticketHistory"
:key="ticketHistory.length"
@@ -64,15 +81,20 @@ import {
CHILL_TICKET_TICKET_INIT_FORM_ERROR,
CHILL_TICKET_TICKET_INIT_FORM_WARNING,
CHILL_TICKET_LIST_LOADING_TICKET_DETAILS,
CHILL_TICKET_LIST_SHOW_ONLY_HISTORY_COMMENTS,
} from "translator";
const store = useStore();
const toast = useToast();
store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
const showOnlyHistoryComments = ref(false);
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 suggestedPersons = computed(() => store.getters.getPersons as Person[]);
const showTicketInitFormModal = ref(false);
@@ -81,21 +103,22 @@ const refreshKey = ref(0);
async function handleFormSubmit(ticketForm: TicketInitForm) {
try {
if (!ticketForm.motive || ticketForm.content.trim() === "") {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
}
if (ticketForm.motive) {
await store.dispatch("createMotive", ticketForm.motive);
} else {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
}
if (ticketForm.content && ticketForm.content.trim() !== "") {
if (ticketForm.content.trim() !== "") {
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("setPersons", ticketForm.persons);

View File

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

View File

@@ -3,6 +3,9 @@
<div class="container-xxl text-primary">
<div class="row">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<div class="small text-muted">
{{ motiveHierarchyLabel(ticket.currentMotive) }}
</div>
<h1>
{{ getTicketTitle(ticket) }}
<peloton-component
@@ -26,7 +29,7 @@
<state-toggle-component v-model="isOpen" />
</div>
<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, {
time: since,
@@ -127,7 +130,7 @@ import {
import { useStore } from "vuex";
// Utils
import { getTicketTitle } from "../utils/utils";
import { getTicketTitle, motiveHierarchyLabel } from "../utils/utils";
const props = defineProps<{
ticket: Ticket;

View File

@@ -1,6 +1,10 @@
<template>
<div class="col-12 fw-bolder">
{{ motiveLabelRecursive(props.motiveHistory.motive) }}
<div class="col-12">
<span class="badge-motive">
<div>
{{ localizeString(props.motiveHistory.motive.label) }}
</div>
</span>
<peloton-component
:stored-objects="motive ? motive.storedObjects : null"
pelotonBtnClass="float-end"
@@ -9,17 +13,17 @@
</template>
<script lang="ts" setup>
import { computed, ComputedRef } from "vue";
import { useStore } from "vuex";
// Types
import {Motive, MotiveHistory, MotiveWithParent} from "../../../../types";
//Utils
//utils
import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
// Types
import { Motive, MotiveHistory } from "../../../../types";
//Components
import PelotonComponent from "../PelotonComponent.vue";
import { computed, ComputedRef } from "vue";
import {motiveLabelRecursive} from "../../utils/utils";
const props = defineProps<{ motiveHistory: MotiveHistory }>();
@@ -29,4 +33,18 @@ const motive: ComputedRef<Motive | null> = computed(() =>
);
</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"
:multiple="false"
:searchable="true"
:internalSearch="false"
:placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)"
:options="flattenedMotives"
:options="options"
:loading="isLoading"
v-model="motive"
class="form-control"
@remove="(value: Motive) => $emit('remove', value)"
@search-change="search"
:disabled="disabled"
>
<template
@@ -57,7 +60,7 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { computed, ref, watch } from "vue";
import VueMultiselect from "vue-multiselect";
// Types
@@ -116,50 +119,73 @@ const motive = computed<Motive | null>({
emit("update:modelValue", value);
},
});
type MotiveOptions = Motive & {
isChild?: boolean;
isParent?: boolean;
level?: number;
displayLabel: string;
breadcrumb: string[];
};
const flattenedMotives = computed(() => {
const result: (Motive & {
isChild?: boolean;
isParent?: boolean;
level?: number;
displayLabel: string;
breadcrumb: string[];
})[] = [];
const motiveOptions: MotiveOptions[] = [];
const options = ref<MotiveOptions[]>([]);
const isLoading = ref<boolean>(false);
const processMotiveRecursively = (
motive: Motive,
isChild = false,
level = 0,
parentBreadcrumb: string[] = [],
) => {
const hasChildren = motive.children && motive.children.length > 0;
const displayLabel = localizeString(motive.label);
const breadcrumb = [...parentBreadcrumb, displayLabel];
function search(query: string) {
isLoading.value = true;
options.value = motiveOptions.filter((m) =>
m.breadcrumb.some((crumb) =>
crumb.toLowerCase().includes(query.trim().toLowerCase()),
),
);
isLoading.value = false;
}
if (props.allowParentSelection || !hasChildren) {
result.push({
...motive,
isChild,
isParent: hasChildren,
level,
breadcrumb,
displayLabel,
});
}
function processMotiveRecursively(
motive: Motive,
isChild = false,
level = 0,
parentBreadcrumb: string[] = [],
) {
const hasChildren = motive.children && motive.children.length > 0;
const displayLabel = localizeString(motive.label);
const breadcrumb = [...parentBreadcrumb, displayLabel];
if (hasChildren) {
motive.children.forEach((childMotive) => {
processMotiveRecursively(childMotive, true, level + 1, breadcrumb);
});
}
};
if (props.allowParentSelection || !hasChildren) {
const optionValue = {
...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) => {
processMotiveRecursively(parentMotive, false, 0, []);
});
}
return result;
});
watch(
() => props.motives,
() => {
fillOptions();
},
{ deep: true },
);
</script>
<style lang="scss" scoped>

View File

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

View File

@@ -1,52 +1,48 @@
<template>
<div class="d-flex justify-content-end">
<div @click="handleClick">
<button type="button" class="btn btn-light position-relative">
{{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }}
<div @click="handleClick">
<button type="button" class="btn btn-light position-relative">
{{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }}
<span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green"
>
{{ previousTickets.length }}
<span class="visually-hidden">Tickets</span>
</span>
</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>
<span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green"
>
{{ previousTickets.length }}
<span class="visually-hidden">Tickets</span>
</span>
</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>
</template>
<script setup lang="ts">

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import {
CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
} from "translator";
import {MotiveWithParent, Ticket, TicketSimple} from "../../../types";
import { MotiveWithParent, Ticket, TicketSimple } from "../../../types";
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é
*/
export function getSinceCreated(createdAt: string, currentTime: Date): string {
if (!createdAt) {
return "";
}
const date = ISOToDatetime(createdAt);
if (date == null) {
if (!date) {
return "";
}
@@ -29,7 +32,6 @@ export function getSinceCreated(createdAt: string, currentTime: Date): string {
const minutesDiff = Math.floor((timeDiff % (1000 * 3600)) / (1000 * 60));
const secondsDiff = Math.floor((timeDiff % (1000 * 60)) / 1000);
// On construit la liste des parties à afficher
const parts: string[] = [];
if (daysDiff > 0) {
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)}`;
}
export function motiveLabelRecursive(motive: MotiveWithParent|null): string {
console.log('test', motive);
export function motiveHierarchyLabel(motive: MotiveWithParent | null): string {
if (null === motive) {
return "";
return "Aucun motif";
}
const str = [];
let m: MotiveWithParent|null = motive;
let m: MotiveWithParent | null = motive;
do {
str.push(localizeString(m.label));
m = m.parent;
} while (m !== null);
return str.reverse().join(" > ");
str.reverse();
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-body">
<div class="wrap-header">
<div class="wh-row d-flex justify-content-between align-items-center">
<div class="wh-col">
<span
class="h2"
style="color: var(--bs-chill-blue); font-variant: all-small-caps"
>
{{ getTicketTitle(ticket) }}
</span>
<div class="wh-row d-flex justify-content-between align-items-top">
<div v-if="null !== ticket.currentMotive && null !== ticket.currentMotive.parent" class="wh-col">
<div class="small text-muted">
{{ motiveHierarchyLabel(ticket.currentMotive) }}
</div>
</div>
<div class="wh-col">
<emergency-component
@@ -23,9 +20,17 @@
</div>
</div>
<div class="wh-row">
<div class="wh-col" v-if="ticket.createdAt">
<div class="wh-col">
<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')"
style="font-style: italic"
>
@@ -105,6 +110,7 @@ import {
getSinceCreated,
formatDateTime,
getTicketTitle,
motiveHierarchyLabel,
} from "../../TicketApp/utils/utils";
// Components

View File

@@ -4,6 +4,7 @@ chill_ticket:
title: "Tickets"
title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}"
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}}"
no_tickets: "Aucun ticket"
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/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
]
}