Créer un composant pour afficher une liste des tickets

This commit is contained in:
Boris Waaub 2025-07-16 09:04:57 +00:00 committed by Julien Fastré
parent c5e6122d2c
commit b7c9b60744
30 changed files with 745 additions and 417 deletions

View File

@ -10,9 +10,11 @@
>
<div class="modal-dialog" :class="modalDialogClass || {}">
<div class="modal-content">
<div class="modal-header">
<div
class="modal-header d-flex justify-content-between align-items-center"
>
<slot name="header"></slot>
<button class="close btn" @click="emits('close')">
<button class="close btn ms-auto" @click="emits('close')">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</div>

View File

@ -132,6 +132,7 @@ interface BaseTicket<
type: "ticket_ticket";
id: number;
externalRef: string;
createdAt: DateTime | null;
currentAddressees: UserGroupOrUser[];
currentPersons: Person[];
currentMotive: null | Motive;
@ -146,7 +147,6 @@ export interface TicketSimple extends BaseTicket<"ticket_ticket:simple"> {
export interface Ticket extends BaseTicket<"ticket_ticket:extended"> {
type_extended: "ticket_ticket:extended";
createdAt: DateTime | null;
createdBy: User | null;
updatedAt: DateTime | null;
updatedBy: User | null;

View File

@ -1,11 +1,12 @@
<template>
<banner-component :ticket="ticket" />
<div class="container-xxl pt-1" style="padding-bottom: 55px">
<ticket-selector-component :tickets="[]" />
<previous-tickets-component />
<ticket-history-list-component :history="ticketHistory" />
</div>
<action-toolbar-component />
</template>
<script setup lang="ts">
import { useToast } from "vue-toast-notification";
import { computed, onMounted } from "vue";
@ -15,7 +16,7 @@ import { useStore } from "vuex";
import { Ticket } from "../../types";
// Components
import TicketSelectorComponent from "./components/TicketSelectorComponent.vue";
import PreviousTicketsComponent from "./components/PreviousTicketsComponent.vue";
import TicketHistoryListComponent from "./components/TicketHistoryListComponent.vue";
import ActionToolbarComponent from "./components/ActionToolbarComponent.vue";
import BannerComponent from "./components/BannerComponent.vue";
@ -27,7 +28,7 @@ store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
const ticket = computed(() => store.getters.getTicket as Ticket);
const ticketHistory = computed(() => store.getters.getDistinctAddressesHistory);
const ticketHistory = computed(() => store.getters.getTicketHistory);
onMounted(async () => {
try {

View File

@ -27,7 +27,7 @@
</div>
<form @submit.prevent="submitAction">
<add-comment-component
<comment-editor-component
v-model="content"
v-if="activeTab === 'add_comment'"
/>
@ -53,12 +53,21 @@
</div>
<div class="row">
<div class="col">
<caller-selector-component v-model="caller" :suggested="[]" />
<persons-selector-component
v-model="caller"
:suggested="[]"
:multiple="false"
:types="['person', 'thirdparty']"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL)"
/>
</div>
<div class="col">
<persons-selector-component
v-model="persons"
:suggested="suggestedPersons"
:multiple="true"
:types="['person']"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL)"
/>
</div>
</div>
@ -82,15 +91,14 @@
<li v-else class="nav-item p-2 go-back">
<button
type="button"
class="btn btn-link p-0"
style="font-size: 1.5rem; line-height: 1; color: #888"
class="btn btn-light"
@click="closeAllActions"
aria-label="Fermer"
title="Fermer"
>
<span aria-hidden="true">&times;</span>
<i class="fa fa-arrow-left"></i>
{{ trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CANCEL) }}
</button>
>
</li>
<li v-for="btn in actionButtons" :key="btn.key" class="nav-item p-2">
<button
@ -130,11 +138,10 @@ import { useStore } from "vuex";
import { useToast } from "vue-toast-notification";
// Component
import MotiveSelectorComponent from "./MotiveSelectorComponent.vue";
import AddresseeSelectorComponent from "./AddresseeSelectorComponent.vue";
import AddCommentComponent from "./AddCommentComponent.vue";
import PersonsSelectorComponent from "./PersonsSelectorComponent.vue";
import CallerSelectorComponent from "./CallerSelectorComponent.vue";
import MotiveSelectorComponent from "./Motive/MotiveSelectorComponent.vue";
import AddresseeSelectorComponent from "./Addressee/AddresseeSelectorComponent.vue";
import CommentEditorComponent from "./Comment/CommentEditorComponent.vue";
import PersonsSelectorComponent from "./Person/PersonsSelectorComponent.vue";
// Translations
import {
@ -152,6 +159,8 @@ import {
CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER,
CHILL_TICKET_TICKET_SET_PERSONS_TITLE,
CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS,
CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL,
CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_SAVE,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS,
@ -159,6 +168,7 @@ import {
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CANCEL,
} from "translator";
// Types
@ -220,7 +230,7 @@ const isOpen = computed(() => store.getters.isOpen);
const motives = computed(() => store.getters.getMotives as Motive[]);
const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]);
const suggestedPersons = computed(() => store.getters.getPersons as Person[]);
console.log("suggestedPersons", suggestedPersons.value);
const hasReturnPath = computed((): boolean => {
const params = new URL(document.location.toString()).searchParams;
return params.has("returnPath");
@ -291,6 +301,7 @@ async function submitAction() {
await store.dispatch("setPersons", {
persons: persons.value,
});
await store.dispatch("fetchTicketsByPerson");
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS));
break;
@ -351,6 +362,6 @@ div.footer-ticket-details {
.fixed-bottom {
position: sticky;
top: 0;
overflow: hidden;
overflow: visible;
}
</style>

View File

@ -37,7 +37,7 @@ import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
} from "../../../../../../../../ChillMainBundle/Resources/public/types";
const props = defineProps<{ addressees: UserGroupOrUser[] }>();

View File

@ -3,12 +3,10 @@
<div class="container-xxl text-primary">
<div class="row">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<h1 v-if="ticket.currentMotive">
#{{ ticket.id }} {{ ticket.currentMotive.label.fr }}
<h1>
{{ getTicketTitle(ticket) }}
</h1>
<p class="chill-no-data-statement" v-else>
{{ trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE) }}
</p>
<h2 v-if="ticket.currentPersons.length">
{{ ticket.currentPersons.map((person) => person.text).join(", ") }}
</h2>
@ -16,7 +14,7 @@
<div class="col-md-6 col-sm-12">
<div class="d-flex justify-content-end">
<toggle-flags />
<emergency-toggle-component />
<span
class="badge text-bg-chill-green text-white"
@ -49,37 +47,42 @@
<Teleport to="#header-ticket-details">
<div class="container-xxl">
<div class="row justify-content-between">
<div class="col-md-4 col-sm-12 d-flex flex-column align-items-start">
<div
class="col-md-4 col-sm-12 d-flex flex-column align-items-start"
v-if="ticket.caller"
>
<h3 class="text-primary">
{{ trans(CHILL_TICKET_TICKET_BANNER_CALLER) }}
{{
trans(CHILL_TICKET_TICKET_BANNER_CALLER, {
count: ticket.caller ? 1 : 0,
})
}}
</h3>
<on-the-fly
v-if="ticket.caller"
:key="ticket.caller.id"
:type="ticket.caller.type"
:id="ticket.caller.id"
:buttonText="ticket.caller.text"
:displayBadge="'true' === 'true'"
action="show"
></on-the-fly>
<person-component :entities="[ticket.caller] as Person[]" />
</div>
<div class="col-md-4 col-sm-12 d-flex flex-column align-items-start">
<div
class="col-md-4 col-sm-12 d-flex flex-column align-items-start"
v-if="ticket.currentPersons.length"
>
<h3 class="text-primary">
{{ trans(CHILL_TICKET_TICKET_BANNER_CONCERNED_USAGER) }}
{{
trans(CHILL_TICKET_TICKET_BANNER_PERSON, {
count: ticket.currentPersons.length,
})
}}
</h3>
<on-the-fly
v-for="person in ticket.currentPersons"
:key="person.id"
:type="person.type"
:id="person.id"
:buttonText="person.textAge"
:displayBadge="'true' === 'true'"
action="show"
></on-the-fly>
<person-component :entities="ticket.currentPersons" />
</div>
<div class="col-md-4 col-sm-12 d-flex flex-column align-items-start">
<div
class="col-md-4 col-sm-12 d-flex flex-column align-items-start"
v-if="ticket.currentAddressees.length"
>
<h3 class="text-primary">
{{ trans(CHILL_TICKET_TICKET_BANNER_SPEAKER) }}
{{
trans(CHILL_TICKET_TICKET_BANNER_SPEAKER, {
count: ticket.currentAddressees.length,
})
}}
</h3>
<addressee-component :addressees="ticket.currentAddressees" />
</div>
@ -99,92 +102,41 @@
import { ref, computed } from "vue";
// Components
import AddresseeComponent from "./AddresseeComponent.vue";
import ToggleFlags from "./ToggleFlags.vue";
import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue";
import AddresseeComponent from "./Addressee/AddresseeComponent.vue";
import EmergencyToggleComponent from "./Emergency/EmergencyToggleComponent.vue";
import PersonComponent from "./Person/PersonComponent.vue";
// Types
import { Ticket } from "../../../types";
import { ISOToDatetime } from "../../../../../../../ChillMainBundle/Resources/public/chill/js/date";
// Translations
import {
trans,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
CHILL_TICKET_TICKET_BANNER_OPEN,
CHILL_TICKET_TICKET_BANNER_CLOSED,
CHILL_TICKET_TICKET_BANNER_SINCE,
CHILL_TICKET_TICKET_BANNER_CONCERNED_USAGER,
CHILL_TICKET_TICKET_BANNER_SPEAKER,
CHILL_TICKET_TICKET_BANNER_DAYS,
CHILL_TICKET_TICKET_BANNER_HOURS,
CHILL_TICKET_TICKET_BANNER_MINUTES,
CHILL_TICKET_TICKET_BANNER_SECONDS,
CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_CALLER,
CHILL_TICKET_TICKET_BANNER_PERSON,
} from "translator";
// Store
import { useStore } from "vuex";
import { Person } from "ChillPersonAssets/types";
import { getTicketTitle } from "../utils/utils";
const props = defineProps<{
defineProps<{
ticket: Ticket;
}>();
const store = useStore();
const today = ref(new Date());
const createdAt = ref(props.ticket.createdAt);
setInterval(() => {
today.value = new Date();
}, 5000);
const isOpen = computed(() => store.getters.isOpen);
const since = computed(() => {
if (createdAt.value == null) {
return "";
}
const date = ISOToDatetime(createdAt.value.datetime);
if (date == null) {
return "";
}
const timeDiff = Math.abs(today.value.getTime() - date.getTime());
const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
const hoursDiff = Math.floor((timeDiff % (1000 * 3600 * 24)) / (1000 * 3600));
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 }));
}
if (hoursDiff > 0 || daysDiff > 0) {
parts.push(trans(CHILL_TICKET_TICKET_BANNER_HOURS, { count: hoursDiff }));
}
if (minutesDiff > 0 || hoursDiff > 0 || daysDiff > 0) {
parts.push(
trans(CHILL_TICKET_TICKET_BANNER_MINUTES, { count: minutesDiff }),
);
}
if (parts.length === 0) {
return trans(CHILL_TICKET_TICKET_BANNER_SECONDS, {
count: secondsDiff,
});
}
if (parts.length > 1) {
const last = parts.pop();
return (
parts.join(", ") +
" " +
trans(CHILL_TICKET_TICKET_BANNER_AND) +
" " +
last
);
}
return parts[0];
return store.getters.getSinceCreated(today.value);
});
</script>

View File

@ -1,85 +0,0 @@
<template>
<pick-entity
uniqid="ticket-person-selector"
:types="['person', 'thirdparty']"
:picked="selectedEntity ? [selectedEntity] : []"
:suggested="suggestedValues"
:multiple="false"
:removable-if-set="true"
:display-picked="true"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL)"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from "vue";
// Components
import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
// Types
import { Entities } from "ChillPersonAssets/types";
// Translations
import {
trans,
CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL,
} from "translator";
const props = defineProps<{
modelValue: Entities | null;
suggested: Entities[];
}>();
const emit =
defineEmits<(event: "update:modelValue", value: Entities | null) => void>();
const selectedEntity = ref<Entities | null>(props.modelValue);
const suggestedValues = ref<Entities[]>([...props.suggested]);
watch(
() => [props.suggested, props.modelValue],
() => {
suggestedValues.value = props.suggested.filter(
(suggested: Entities) =>
suggested.id === selectedEntity.value?.id &&
suggested.type === selectedEntity.value?.type,
);
},
{ immediate: true, deep: true },
);
function addNewEntity({ entity }: { entity: Entities }) {
selectedEntity.value = entity;
emit("update:modelValue", selectedEntity.value);
}
function removeEntity() {
selectedEntity.value = null;
emit("update:modelValue", null);
}
</script>
<style scoped lang="scss">
ul.person-list {
list-style-type: none;
& > li {
display: inline-block;
border: 1px solid transparent;
border-radius: 6px;
button.remove-person {
opacity: 10%;
}
}
& > li:hover {
border: 1px solid white;
button.remove-person {
opacity: 100%;
}
}
}
</style>

View File

@ -11,7 +11,7 @@ import { marked } from "marked";
import DOMPurify from "dompurify";
// Types
import { Comment } from "../../../types";
import { Comment } from "../../../../types";
defineProps<{ commentHistory: Comment }>();

View File

@ -1,12 +1,15 @@
<template>
<div class="col-12 fw-bolder">
{{ motiveHistory.motive.label.fr }}
{{ localizeTranslatableString(motiveHistory.motive.label) }}
</div>
</template>
<script lang="ts" setup>
// Types
import { MotiveHistory } from "../../../types";
import { MotiveHistory } from "../../../../types";
//Utils
import { localizeTranslatableString } from "../../utils/utils";
defineProps<{ motiveHistory: MotiveHistory }>();
</script>

View File

@ -27,7 +27,7 @@ import { ref, watch } from "vue";
import VueMultiselect from "vue-multiselect";
// Types
import { Motive } from "../../../types";
import { Motive } from "../../../../types";
// Translations
import {

View File

@ -28,10 +28,10 @@ defineProps<{ entities: Person[] | Thirdparty[] }>();
<style lang="scss" scoped>
ul.persons-list {
list-style-type: none;
margin: 0;
padding: 0;
& > li {
display: inline-block;
margin: 0 0.15rem;
}
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<pick-entity
uniqid="ticket-person-selector"
:types="types"
:picked="pickedEntities"
:suggested="suggestedValues"
:multiple="multiple"
:removable-if-set="true"
:display-picked="true"
:label="label"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits, computed } from "vue";
// Components
import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
// Types
import { Entities, EntityType } from "ChillPersonAssets/types";
const props = defineProps<{
modelValue: Entities[] | Entities | null;
suggested: Entities[];
multiple: boolean;
types: EntityType[];
label: string;
}>();
const emit = defineEmits<{
"update:modelValue": [value: Entities[] | Entities | null];
}>();
// Valeurs par défaut
const multiple = props.multiple;
const types = props.types;
const label = props.label;
// État local
const selectedEntities = ref<Entities[]>(
multiple
? [...((props.modelValue as Entities[]) || [])]
: props.modelValue
? [props.modelValue as Entities]
: [],
);
const suggestedValues = ref<Entities[]>([...props.suggested]);
// Entités sélectionnées pour le composant PickEntity
const pickedEntities = computed(() =>
multiple ? selectedEntities.value : selectedEntities.value.slice(0, 1),
);
watch(
() => [props.suggested, props.modelValue],
() => {
// Mise à jour des entités sélectionnées
selectedEntities.value = multiple
? [...((props.modelValue as Entities[]) || [])]
: props.modelValue
? [props.modelValue as Entities]
: [];
// Filtrage des suggestions
if (multiple) {
suggestedValues.value = props.suggested.filter(
(suggested: Entities) =>
!(props.modelValue as Entities[])?.some(
(selected: Entities) =>
suggested.id === selected.id && suggested.type === selected.type,
),
);
} else {
const currentEntity = props.modelValue as Entities | null;
suggestedValues.value = props.suggested.filter(
(suggested: Entities) =>
!(
currentEntity &&
suggested.id === currentEntity.id &&
suggested.type === currentEntity.type
),
);
}
},
{ immediate: true, deep: true },
);
function addNewEntity({ entity }: { entity: Entities }) {
if (multiple) {
selectedEntities.value.push(entity);
emit("update:modelValue", selectedEntities.value);
} else {
selectedEntities.value = [entity];
emit("update:modelValue", entity);
}
}
function removeEntity({ entity }: { entity: Entities }) {
if (multiple) {
const index = selectedEntities.value.findIndex(
(selectedEntity) => selectedEntity === entity,
);
if (index !== -1) {
selectedEntities.value.splice(index, 1);
}
emit("update:modelValue", selectedEntities.value);
} else {
selectedEntities.value = [];
emit("update:modelValue", null);
}
}
</script>
<style scoped lang="scss">
ul.person-list {
list-style-type: none;
& > li {
display: inline-block;
border: 1px solid transparent;
border-radius: 6px;
button.remove-person {
opacity: 10%;
}
}
& > li:hover {
border: 1px solid white;
button.remove-person {
opacity: 100%;
}
}
}
</style>

View File

@ -1,90 +0,0 @@
<template>
<pick-entity
uniqid="ticket-person-selector"
:types="['person']"
:picked="selectedEntities"
:suggested="suggestedValues"
:multiple="false"
:removable-if-set="true"
:display-picked="true"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL)"
@add-new-entity="addNewEntity"
@remove-entity="removeEntity"
/>
</template>
<script setup lang="ts">
import { ref, watch, defineProps, defineEmits } from "vue";
// Components
import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
// Types
import { Entities } from "ChillPersonAssets/types";
// Translations
import { trans, CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL } from "translator";
const props = defineProps<{
modelValue: Entities[];
suggested: Entities[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: Entities[]];
}>();
const selectedEntities = ref<Entities[]>([...props.modelValue]);
const suggestedValues = ref<Entities[]>([...props.suggested]);
watch(
() => [props.suggested, props.modelValue],
() => {
suggestedValues.value = props.suggested.filter(
(suggested: Entities) =>
!props.modelValue.some(
(selected: Entities) =>
suggested.id === selected.id && suggested.type === selected.type,
),
);
},
{ immediate: true, deep: true },
);
function addNewEntity({ entity }: { entity: Entities }) {
selectedEntities.value.push(entity);
emit("update:modelValue", selectedEntities.value);
}
function removeEntity({ entity }: { entity: Entities }) {
const index = selectedEntities.value.findIndex(
(selectedEntity) => selectedEntity === entity,
);
if (index !== -1) {
selectedEntities.value.splice(index, 1);
}
emit("update:modelValue", selectedEntities.value);
}
</script>
<style scoped lang="scss">
ul.person-list {
list-style-type: none;
& > li {
display: inline-block;
border: 1px solid transparent;
border-radius: 6px;
button.remove-person {
opacity: 10%;
}
}
& > li:hover {
border: 1px solid white;
button.remove-person {
opacity: 100%;
}
}
}
</style>

View File

@ -0,0 +1,166 @@
<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) }}
<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>
<TicketListComponent
:tickets="previousTickets"
@view-ticket="handleViewTicket"
@edit-ticket="handleEditTicket"
/>
</template>
</Modal>
<!-- Modal for ticket history -->
<Modal
v-if="
showTicketHistoryModal &&
selectedTicketId !== null &&
previousTicketHistory
"
:show="showTicketHistoryModal"
modal-dialog-class="modal-xl"
@close="closeHistoryModal"
>
<template #header>
<h3 class="modal-title">
{{ getTicketTitle(previousTicketDetails) }}
</h3>
</template>
<template #body>
<TicketHistoryListComponent
v-if="previousTicketHistory.length > 0"
:history="previousTicketHistory"
/>
<div v-else class="text-center p-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">Chargement...</span>
</div>
</div>
</template>
<template #footer>
<button
class="btn btn-edit"
@click="handleEditTicket(selectedTicketId)"
>
{{ trans(EDIT) }}
</button>
</template>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useStore } from "vuex";
// Components
import Modal from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue";
import TicketListComponent from "./TicketListComponent.vue";
import TicketHistoryListComponent from "./TicketHistoryListComponent.vue";
// Translations
import {
trans,
CHILL_TICKET_TICKET_PREVIOUS_TICKETS,
CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS,
EDIT,
} from "translator";
// Types
import { Person } from "ChillPersonAssets/types";
import { TicketHistoryLine, TicketSimple } from "../../../types";
// Utils
import { getTicketTitle } from "../utils/utils";
const store = useStore();
const showPreviousTicketModal = ref(false);
const showTicketHistoryModal = ref(false);
const selectedTicketId = ref<number | null>(null);
const currentPersons = computed(
() => store.getters.getCurrentPersons as Person[],
);
const previousTickets = computed(
() => store.getters.getPreviousTickets as TicketSimple[],
);
const previousTicketHistory = computed(
() => store.getters.getPreviousTicketHistory as TicketHistoryLine[],
);
const previousTicketDetails = computed(() => {
return store.getters.getPreviousTicketDetails as TicketSimple;
});
onMounted(async () => {
try {
await store.dispatch("fetchTicketsByPerson");
} catch (error) {
console.error("Erreur lors du chargement des tickets:", error);
}
});
function handleClick() {
showPreviousTicketModal.value = true;
}
function closeModal() {
showPreviousTicketModal.value = false;
}
async function handleViewTicket(ticketId: number) {
selectedTicketId.value = ticketId;
showTicketHistoryModal.value = true;
try {
await store.dispatch("fetchPreviousTicketDetails", ticketId);
} catch (error) {
console.error("Erreur lors du chargement de l'historique:", error);
}
}
function handleEditTicket(ticketId: number) {
window.location.href = `/fr/ticket/ticket/${ticketId}/edit`;
}
function closeHistoryModal() {
showTicketHistoryModal.value = false;
selectedTicketId.value = null;
}
</script>
<style lang="scss" scoped></style>

View File

@ -13,36 +13,11 @@
{{ trans(CHILL_TICKET_TICKET_BANNER_CLOSED) }}
</template>
</span>
<!--
<span
class="text-chill-green mx-2"
style="
font-size: 1rem;
max-width: 80px;
white-space: normal;
word-break: break-word;
"
v-if="props.new_state == 'open'"
>
{{ trans(CHILL_TICKET_TICKET_BANNER_OPEN) }}
</span>
<span
class="text-chill-red mx-2"
style="
font-size: 1rem;
max-width: 80px;
white-space: normal;
word-break: break-word;
"
v-else-if="props.new_state == 'closed'"
>
{{ trans(CHILL_TICKET_TICKET_BANNER_CLOSED) }}
</span> -->
</template>
<script setup lang="ts">
// Types
import { StateChange } from "../../../types";
import { StateChange } from "../../../../types";
// Translations
import {
@ -50,6 +25,7 @@ import {
CHILL_TICKET_TICKET_BANNER_OPEN,
CHILL_TICKET_TICKET_BANNER_CLOSED,
} from "translator";
const props = defineProps<StateChange>();
</script>

View File

@ -1,18 +0,0 @@
<template>
<div class="col-12">
<addressee-component :addressees="addresseeState.addressees" />
</div>
</template>
<script setup lang="ts">
// Types
import { AddresseeState } from "../../../types";
// Components
import AddresseeComponent from "./AddresseeComponent.vue";
defineProps<{
addresseeState: AddresseeState;
}>();
</script>
<style lang="scss" scoped></style>

View File

@ -1,16 +0,0 @@
<template>
<p>Ticket créé par {{ props.by.text }}</p>
</template>
<script setup lang="ts">
// Types
import { User } from "../../../../../../../ChillMainBundle/Resources/public/types";
interface TicketHistoryCreateComponentConfig {
by: User;
}
const props = defineProps<TicketHistoryCreateComponentConfig>();
</script>
<style scoped lang="scss"></style>

View File

@ -13,12 +13,12 @@
<div class="d-flex align-items-center fw-bold">
<i :class="`${actionIcons[history_line.event_type]} me-1`"></i>
<span>{{ explainSentence(history_line) }}</span>
<TicketHistoryStateComponent
<state-component
:new_state="history_line.data.new_state"
v-if="history_line.event_type == 'state_change'"
/>
<TicketHistoryEmergencyComponent
v-if="history_line.event_type == 'emergency_change'"
<emergency-component
v-else-if="history_line.event_type == 'emergency_change'"
:new_emergency="history_line.data.new_emergency"
/>
</div>
@ -37,37 +37,35 @@
<div
class="card-body row"
v-if="
!['state_change', 'emergency_change'].includes(history_line.event_type)
!['state_change', 'emergency_change', 'create_ticket'].includes(
history_line.event_type,
)
"
>
<ticket-history-person-component
<person-component
:entities="history_line.data.persons"
v-if="history_line.event_type == 'persons_state'"
/>
<ticket-history-person-component
<person-component
:entities="
history_line.data.new_caller
? ([history_line.data.new_caller] as Person[] | Thirdparty[])
: []
"
v-if="history_line.event_type == 'set_caller'"
v-else-if="history_line.event_type == 'set_caller'"
/>
<ticket-history-motive-component
<motive-component
:motiveHistory="history_line.data"
v-else-if="history_line.event_type == 'set_motive'"
/>
<ticket-history-comment-component
<comment-component
:commentHistory="history_line.data"
v-else-if="history_line.event_type == 'add_comment'"
/>
<ticket-history-addressee-component
:addresseeState="history_line.data"
<addressee-component
:addressees="history_line.data.addressees"
v-else-if="history_line.event_type == 'addressees_state'"
/>
<ticket-history-create-component
:by="history_line.by"
v-else-if="history_line.event_type == 'create_ticket'"
/>
</div>
</div>
</template>
@ -82,13 +80,12 @@ import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types";
// Components
import TicketHistoryPersonComponent from "./TicketHistoryPersonComponent.vue";
import TicketHistoryMotiveComponent from "./TicketHistoryMotiveComponent.vue";
import TicketHistoryCommentComponent from "./TicketHistoryCommentComponent.vue";
import TicketHistoryAddresseeComponent from "./TicketHistoryAddresseeComponent.vue";
import TicketHistoryCreateComponent from "./TicketHistoryCreateComponent.vue";
import TicketHistoryStateComponent from "./TicketHistoryStateComponent.vue";
import TicketHistoryEmergencyComponent from "./TicketHistoryEmergencyComponent.vue";
import PersonComponent from "./Person/PersonComponent.vue";
import MotiveComponent from "./Motive/MotiveComponent.vue";
import CommentComponent from "./Comment/CommentComponent.vue";
import AddresseeComponent from "./Addressee/AddresseeComponent.vue";
import StateComponent from "./State/StateComponent.vue";
import EmergencyComponent from "./Emergency/EmergencyComponent.vue";
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";

View File

@ -0,0 +1,39 @@
<template>
<div class="ticket-list-container">
<div v-if="tickets.length === 0" class="chill-no-data-statement">
{{ trans(CHILL_TICKET_LIST_NO_TICKETS) }}
</div>
<div v-else class="flex-table">
<TicketListItemComponent
v-for="ticket in tickets"
:key="ticket.id"
:ticket="ticket"
@view-ticket="$emit('view-ticket', $event)"
@edit-ticket="$emit('edit-ticket', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
// Types
import { TicketSimple } from "../../../types";
// Components
import TicketListItemComponent from "./TicketListItemComponent.vue";
// Translations
import { trans, CHILL_TICKET_LIST_NO_TICKETS } from "translator";
defineProps<{
tickets: TicketSimple[];
}>();
defineEmits<{
"view-ticket": [ticketId: number];
"edit-ticket": [ticketId: number];
}>();
</script>

View File

@ -0,0 +1,128 @@
<template>
<div class="card rounded-4 mb-3">
<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
v-if="ticket.currentMotive"
class="h2"
style="color: var(--bs-chill-blue); font-variant: all-small-caps"
>
{{ getTicketTitle(ticket) }}
</span>
</div>
<div class="wh-col">
<emergency-component
:new_emergency="ticket.emergency"
v-if="ticket.emergency"
/>
</div>
<div class="wh-col">
<state-component
v-if="ticket.currentState"
:new_state="ticket.currentState"
/>
</div>
</div>
<div class="wh-row">
<div class="wh-col">#{{ ticket.id }}</div>
<div class="wh-col" v-if="ticket.createdAt">
<span
v-if="ticket"
:title="formatDateTime(ticket.createdAt.datetime, 'long', 'long')"
style="font-style: italic"
>
{{ getSinceCreated(ticket.createdAt.datetime, new Date()) }}
</span>
</div>
</div>
</div>
</div>
<div class="card-body pt-0">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title text-end">
<h3>Attribué à</h3>
</div>
<div class="wl-col list">
<addressee-component :addressees="ticket.currentAddressees" />
</div>
</div>
<div class="wl-row">
<div class="wl-col title text-end">
<h3>Patients concernés</h3>
</div>
<div class="wl-col list">
<person-component :entities="ticket.currentPersons" />
</div>
</div>
<div class="wl-row">
<div class="wl-col title text-end">
<h3>Appelants</h3>
</div>
<div class="wl-col list">
<person-component
:entities="
ticket.caller
? ([ticket.caller] as Person[] | Thirdparty[])
: []
"
/>
</div>
</div>
</div>
</div>
<hr class="my-0" />
<div class="card-footer bg-transparent border-0">
<ul class="record_actions mb-0">
<li>
<button
class="btn btn-view btn-outline-secondary"
type="button"
@click="$emit('view-ticket', ticket.id)"
/>
</li>
<li>
<button
class="btn btn-update btn-outline-primary"
type="button"
@click="emit('edit-ticket', ticket.id)"
/>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from "vue";
// Types
import { TicketSimple } from "../../../types";
import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types";
// Utils
import {
getSinceCreated,
formatDateTime,
getTicketTitle,
} from "../utils/utils";
// Components
import EmergencyComponent from "./Emergency/EmergencyComponent.vue";
import StateComponent from "./State/StateComponent.vue";
import AddresseeComponent from "./Addressee/AddresseeComponent.vue";
import PersonComponent from "./Person/PersonComponent.vue";
defineProps<{
ticket: TicketSimple;
}>();
const emit = defineEmits<{
"edit-ticket": [ticketId: number];
"view-ticket": [ticketId: number];
}>();
</script>

View File

@ -1,36 +0,0 @@
<template>
<div class="d-flex justify-content-end">
<div class="btn-group" @click="handleClick">
<button
type="button"
class="btn btn-light dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false"
>
{{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }}
<span
class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green"
>
{{ tickets.length }}
<span class="visually-hidden">Tickets</span>
</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
// Translations
import { trans, CHILL_TICKET_TICKET_PREVIOUS_TICKETS } from "translator";
// Types
import { Ticket } from "../../../types";
defineProps<{ tickets: Ticket[] }>();
function handleClick() {
alert("Sera disponible plus tard");
}
</script>
<style lang="scss" scoped></style>

View File

@ -1,18 +1,23 @@
import { Module } from "vuex";
import { RootState } from "..";
import { Ticket, TicketEmergencyState } from "../../../../types";
import { Ticket, TicketEmergencyState, TicketSimple } from "../../../../types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types";
import { getSinceCreated } from "../../utils/utils";
export interface State {
ticket: Ticket;
previous_tickets: TicketSimple[];
previous_ticket_details: Ticket;
action_icons: object;
}
export const moduleTicket: Module<State, RootState> = {
state: () => ({
ticket: {} as Ticket,
previous_tickets: [] as TicketSimple[],
previous_ticket_details: {} as Ticket,
action_icons: {
add_person: "fa fa-user-plus",
add_comment: "fa fa-comment",
@ -37,18 +42,42 @@ export const moduleTicket: Module<State, RootState> = {
);
return state.ticket;
},
getTicketHistory(state) {
return state.ticket.history;
},
getPreviousTickets(state) {
return state.previous_tickets;
},
getPreviousTicketHistory(state) {
return state.previous_ticket_details.history;
},
getPreviousTicketDetails(state) {
return state.previous_ticket_details;
},
getCurrentPersons(state) {
return state.ticket.currentPersons ?? [];
},
getActionIcons(state) {
return state.action_icons;
},
getDistinctAddressesHistory(state) {
return state.ticket.history;
getSinceCreated: (state) => (currentTime: Date) => {
if (!state.ticket.createdAt) {
return "";
}
return getSinceCreated(state.ticket.createdAt.datetime, currentTime);
},
},
mutations: {
setTicket(state, ticket: Ticket) {
state.ticket = ticket;
},
setPreviousTickets(state, previousTickets: TicketSimple[]) {
state.previous_tickets = previousTickets;
},
setPreviousTicketDetails(state, ticket: Ticket) {
state.previous_ticket_details = ticket;
},
},
actions: {
async closeTicket({ commit, state }) {
@ -87,5 +116,39 @@ export const moduleTicket: Module<State, RootState> = {
throw error.name;
}
},
async fetchTicketsByPerson({ commit, state }) {
try {
if (state.ticket.currentPersons.length === 0) {
commit("setPreviousTickets", []);
return;
}
const { results }: { results: TicketSimple[] } = await makeFetch(
"GET",
`/api/1.0/ticket/ticket/list?byPerson=${state.ticket.currentPersons.map((person) => person.id).join(",")}`,
);
const excludeCurrentTicket = results.filter(
(ticket) => ticket.id !== state.ticket.id,
);
commit("setPreviousTickets", excludeCurrentTicket);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
async fetchPreviousTicketDetails({ commit }, ticketId: number) {
try {
const ticket: Ticket = await makeFetch(
"GET",
`/api/1.0/ticket/ticket/${ticketId}`,
);
commit("setPreviousTicketDetails", ticket);
return ticket;
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
},
};

View File

@ -0,0 +1,94 @@
import { ISOToDatetime } from "../../../../../../../../Bundle/ChillMainBundle/Resources/public/chill/js/date";
import {
trans,
CHILL_TICKET_TICKET_BANNER_DAYS,
CHILL_TICKET_TICKET_BANNER_HOURS,
CHILL_TICKET_TICKET_BANNER_MINUTES,
CHILL_TICKET_TICKET_BANNER_SECONDS,
CHILL_TICKET_TICKET_BANNER_AND,
CHILL_TICKET_TICKET_BANNER_NO_MOTIVE,
} from "translator";
import { Ticket, TicketSimple } from "../../../types";
/**
* Calcule et formate le temps écoulé depuis une date de création
* @param createdAt La date de création au format ISO
* @param currentTime La date actuelle
* @returns Une chaîne formatée représentant le temps écoulé
*/
export function getSinceCreated(createdAt: string, currentTime: Date): string {
const date = ISOToDatetime(createdAt);
if (date == null) {
return "";
}
const timeDiff = Math.abs(currentTime.getTime() - date.getTime());
const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
const hoursDiff = Math.floor((timeDiff % (1000 * 3600 * 24)) / (1000 * 3600));
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 }));
}
if (hoursDiff > 0 || daysDiff > 0) {
parts.push(
trans(CHILL_TICKET_TICKET_BANNER_HOURS, {
count: hoursDiff,
}),
);
}
if (minutesDiff > 0 || hoursDiff > 0 || daysDiff > 0) {
parts.push(
trans(CHILL_TICKET_TICKET_BANNER_MINUTES, {
count: minutesDiff,
}),
);
}
if (parts.length === 0) {
return trans(CHILL_TICKET_TICKET_BANNER_SECONDS, {
count: secondsDiff,
});
}
if (parts.length > 1) {
const last = parts.pop();
return (
parts.join(", ") +
" " +
trans(CHILL_TICKET_TICKET_BANNER_AND) +
" " +
last
);
}
return parts[0];
}
export function localizeTranslatableString(
translatableString: Record<string, string> | string,
): string {
// This would be implemented based on your localization logic
if (typeof translatableString === "string") {
return translatableString;
}
// Assuming it's an object with locale keys
return translatableString?.fr || translatableString?.en || "Unknown";
}
export function formatDateTime(
dateTime: string,
dateStyle: string,
timeStyle: string,
): string {
return new Date(dateTime).toLocaleString("fr-FR", {
dateStyle: dateStyle as "short" | "medium" | "long" | "full",
timeStyle: timeStyle as "short" | "medium" | "long" | "full",
});
}
export function getTicketTitle(ticket: Ticket | TicketSimple): string {
if (ticket.currentMotive) {
return `#${ticket.id} ${localizeTranslatableString(ticket.currentMotive.label)}`;
}
return `#${ticket.id} ${trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE)}`;
}

View File

@ -45,6 +45,7 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'type' => 'ticket_ticket',
'id' => $object->getId(),
'externalRef' => $object->getExternalRef(),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'currentPersons' => $this->normalizer->normalize($object->getPersons(), $format, [
'groups' => 'read',
]),
@ -65,7 +66,6 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
$data += [
'type_extended' => 'ticket_ticket:extended',
'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])),
'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context),
'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context),
'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context),
'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context),

View File

@ -1,6 +1,8 @@
chill_ticket:
list:
title: Tickets
title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}"
no_tickets: "Aucun ticket"
filter:
to_me: Tickets qui me sont attribués
in_alert: Tickets en alerte (délai de résolution dépassé)
@ -51,9 +53,9 @@ chill_ticket:
success: "Appelants et usagers mis à jour"
error: "Aucun usager sélectionné"
banner:
concerned_usager: "Usagers concernés"
speaker: "Attribué à"
caller: "Appelant"
person: "{count, plural, =0 {Aucun usager concerné} =1 {Usager concerné} other {Usagers concernés}}"
speaker: "{count, plural, =0 {Aucun intervenant} =1 {Attribué à} other {Attribués à}}"
caller: "{count, plural, =0 {Aucun appelant} =1 {Appelant} other {Appelants}}"
open: "Ouvert"
closed: "Fermé"
since: "Depuis {time}"

View File

@ -282,12 +282,12 @@ class TicketNormalizerTest extends KernelTestCase
'currentState',
'emergency',
'caller',
'createdAt',
];
// Keys that should not be present in read:simple normalization
$unexpectedKeys = [
'history',
'createdAt',
'updatedAt',
'updatedBy',
'createdBy',