Merge branch 'multiple-tasks-from-board-78' into 'ticket-app-master'

Merge request contenant différentes tâches provenant du board 78

See merge request Chill-Projet/chill-bundles!864
This commit is contained in:
2025-08-18 15:32:00 +00:00
27 changed files with 1397 additions and 301 deletions

View File

@@ -6,15 +6,19 @@ import {
} from "ChillMainAssets/types";
import { Person } from "ChillPersonAssets/types";
import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types";
import { StoredObject } from "ChillDocStoreAssets/types";
export interface Motive {
type: "ticket_motive";
id: number;
active: boolean;
label: TranslatableString;
makeTicketEmergency: TicketEmergencyState;
storedObjects: StoredObject[];
supplementaryComments: { label: string }[];
}
export type TicketState = "open" | "closed";
export type TicketState = "open" | "closed" | "close";
export type TicketEmergencyState = "yes" | "no";
@@ -55,9 +59,7 @@ export interface Comment {
updatedBy: User | null;
updatedAt: DateTime | null;
deleted: boolean;
supplementaryComments: {
label: string;
};
supplementaryComments: { label: string }[];
}
export interface AddresseeHistory {
@@ -162,7 +164,7 @@ export interface TicketFilters {
byCreatedAfter: string;
byCreatedBefore: string;
byResponseTimeExceeded: boolean;
byMyTickets: boolean;
byAddresseeToMe: boolean;
}
export interface TicketFilterParams {
@@ -173,5 +175,12 @@ export interface TicketFilterParams {
byCreatedAfter?: string;
byCreatedBefore?: string;
byResponseTimeExceeded?: string;
byMyTickets?: boolean;
byAddresseeToMe?: boolean;
}
export interface TicketInitForm {
content: string;
motive?: Motive;
persons: Person[];
caller: Person | null;
}

View File

@@ -1,25 +1,68 @@
<template>
<banner-component :ticket="ticket" />
<div class="container-xxl pt-1" style="padding-bottom: 55px">
<previous-tickets-component />
<ticket-history-list-component :history="ticketHistory" />
<div class="container-xxl pt-1" style="padding-bottom: 55px" v-if="!loading">
<previous-tickets-component :key="refreshKey" />
<ticket-history-list-component
:history="ticketHistory"
:key="ticketHistory.length"
/>
</div>
<action-toolbar-component />
<div v-else class="text-center p-4">
<div class="spinner-border" role="status">
<span class="visually-hidden">
{{ trans(CHILL_TICKET_LIST_LOADING_TICKET_DETAILS) }}
</span>
</div>
<div>{{ trans(CHILL_TICKET_LIST_LOADING_TICKET_DETAILS) }}</div>
</div>
<action-toolbar-component :key="refreshKey" v-if="!loading" />
<Modal
v-if="showTicketInitFormModal"
:show="showTicketInitFormModal"
modal-dialog-class="modal-lg"
@close="closeModal"
>
<template #header>
<h3 class="modal-title">
{{ trans(CHILL_TICKET_TICKET_INIT_FORM_TITLE) }}
</h3>
</template>
<template #body>
<ticket-init-form-component
:motives="motives"
:suggested-persons="suggestedPersons"
@submit="handleFormSubmit"
/>
</template>
</Modal>
</template>
<script setup lang="ts">
import { useToast } from "vue-toast-notification";
import { computed, onMounted } from "vue";
import { computed, onMounted, ref } from "vue";
import { useStore } from "vuex";
// Types
import { Ticket } from "../../types";
import { Ticket, Motive, TicketInitForm } from "../../types";
import { Person } from "ChillPersonAssets/types";
// Components
import Modal from "../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue";
import PreviousTicketsComponent from "./components/PreviousTicketsComponent.vue";
import TicketHistoryListComponent from "../TicketList/components/TicketHistoryListComponent.vue";
import ActionToolbarComponent from "./components/ActionToolbarComponent.vue";
import BannerComponent from "./components/BannerComponent.vue";
import TicketInitFormComponent from "./components/TicketInitFormComponent.vue";
// Translations
import {
trans,
CHILL_TICKET_TICKET_INIT_FORM_TITLE,
CHILL_TICKET_TICKET_INIT_FORM_SUCCESS,
CHILL_TICKET_TICKET_INIT_FORM_ERROR,
CHILL_TICKET_LIST_LOADING_TICKET_DETAILS,
} from "translator";
const store = useStore();
const toast = useToast();
@@ -27,19 +70,72 @@ const toast = useToast();
store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
const ticket = computed(() => store.getters.getTicket as Ticket);
const ticketHistory = computed(() => store.getters.getTicketHistory);
const motives = computed(() => store.getters.getMotives as Motive[]);
const suggestedPersons = computed(() => store.getters.getPersons as Person[]);
const showTicketInitFormModal = ref(false);
const loading = ref(true);
const refreshKey = ref(0);
async function handleFormSubmit(ticketForm: TicketInitForm) {
try {
if (ticketForm.motive) {
await store.dispatch("createMotive", ticketForm.motive);
}
if (ticketForm.content && ticketForm.content.trim() !== "") {
await store.dispatch("createComment", ticketForm.content);
}
await store.dispatch("setPersons", ticketForm.persons);
// Rafraîchir les données nécessaires
await store.dispatch("fetchTicketHistory");
await store.dispatch("fetchPreviousTickets");
// Forcer le rafraîchissement des composants
refreshKey.value++;
toast.success(trans(CHILL_TICKET_TICKET_INIT_FORM_SUCCESS));
closeModal();
} catch (error) {
toast.error(
(error as string) || trans(CHILL_TICKET_TICKET_INIT_FORM_ERROR),
);
}
}
function closeModal() {
showTicketInitFormModal.value = false;
}
onMounted(async () => {
try {
await store.dispatch("getCurrentUser");
await store.dispatch("fetchMotives");
await store.dispatch("fetchUserGroups");
await store.dispatch("fetchUsers");
await store.dispatch("getSuggestedPersons");
showTicketInitFormModal.value = store.getters.isNewTicket;
} catch (error) {
toast.error(error as string);
} finally {
loading.value = false;
}
});
</script>
<style lang="scss" scoped></style>
<style lang="scss" scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
font-size: 2rem;
color: #333;
}
</style>

View File

@@ -29,6 +29,7 @@
<form @submit.prevent="submitAction">
<comment-editor-component
v-model="content"
:motive="motive"
v-if="activeTab === 'add_comment'"
/>
<addressee-selector-component
@@ -169,6 +170,7 @@ import {
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CANCEL,
CHILL_TICKET_TICKET_SET_PERSONS_ERROR,
} from "translator";
// Types
@@ -249,71 +251,75 @@ const returnPath = computed((): string => {
return returnPath;
});
const motive = ref(
ticket.value.currentMotive ? ticket.value.currentMotive : ({} as Motive),
);
const motive = ref(ticket.value.currentMotive as Motive);
const content = ref("" as Comment["content"]);
const addressees = ref(ticket.value.currentAddressees as UserGroupOrUser[]);
const persons = ref(ticket.value.currentPersons as Person[]);
const caller = ref(ticket.value.caller as Person);
async function submitAction() {
try {
switch (activeTab.value) {
case "add_comment":
if (!content.value) {
toast.error(trans(CHILL_TICKET_TICKET_ADD_COMMENT_ERROR));
} else {
await store.dispatch("createComment", {
ticketId: ticket.value.id,
content: content.value,
});
content.value = "";
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_ADD_COMMENT_SUCCESS));
}
break;
case "set_motive":
if (!motive.value.id) {
toast.error(trans(CHILL_TICKET_TICKET_SET_MOTIVE_ERROR));
} else {
await store.dispatch("createMotive", {
ticketId: ticket.value.id,
motive: motive.value,
});
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_SET_MOTIVE_SUCCESS));
}
break;
case "addressees_state":
if (!addressees.value.length) {
toast.error(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR));
} else {
await store.dispatch("setAdressees", {
ticketId: ticket.value.id,
addressees: addressees.value,
});
switch (activeTab.value) {
case "add_comment":
if (!content.value) {
toast.error(trans(CHILL_TICKET_TICKET_ADD_COMMENT_ERROR));
} else {
await store.dispatch("createComment", content.value);
content.value = "";
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_ADD_COMMENT_SUCCESS));
}
break;
case "set_motive":
if (!motive.value) {
toast.error(trans(CHILL_TICKET_TICKET_SET_MOTIVE_ERROR));
} else {
await store.dispatch("createMotive", motive.value);
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_SET_MOTIVE_SUCCESS));
}
break;
case "addressees_state":
if (addressees.value.length) {
try {
await store.dispatch(
"setAddressees",
addressees.value as UserGroupOrUser[],
);
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_SUCCESS));
} catch (error) {
console.error(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR), error);
toast.error(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR));
}
break;
case "persons_state":
await store.dispatch("setPersons", {
persons: persons.value,
});
await store.dispatch("fetchTicketsByPerson");
activeTab.value = "";
} else {
await store.dispatch("setAddressees", [] as UserGroupOrUser[]);
toast.success(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_SUCCESS));
}
break;
case "persons_state":
if (persons.value.length) {
await store.dispatch("setPersons", persons.value);
try {
await store.dispatch("fetchTicketList", {
byPerson: persons.value.map((person) => person.id),
});
activeTab.value = "";
toast.success(trans(CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS));
} catch (error) {
console.error(trans(CHILL_TICKET_TICKET_SET_PERSONS_ERROR), error);
toast.error(trans(CHILL_TICKET_TICKET_SET_PERSONS_ERROR));
}
} else {
store.dispatch("setPersons", [] as Person[]);
toast.success(trans(CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS));
break;
}
} catch (error) {
toast.error(error as string);
}
break;
}
}
async function closeTicket() {
try {
await store.dispatch("closeTicket");
await store.dispatch("setTicketState", "close");
closeAllActions();
toast.success(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS));
} catch (error) {
@@ -324,7 +330,7 @@ async function closeTicket() {
async function reopenTicket() {
try {
await store.dispatch("reopenTicket");
await store.dispatch("setTicketState", "open");
toast.success(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS));
} catch (error) {
console.error(error);
@@ -337,7 +343,7 @@ function closeAllActions() {
}
watch(caller, async (newCaller) => {
await store.dispatch("setCaller", { caller: newCaller });
await store.dispatch("setCaller", newCaller);
await store.dispatch("getSuggestedPersons");
});
</script>

View File

@@ -8,31 +8,21 @@
</h1>
<h2 v-if="ticket.currentPersons.length">
{{ ticket.currentPersons.map((person) => person.text).join(", ") }}
{{
ticket.currentPersons
.map((person: Person) => person.text)
.join(", ")
}}
</h2>
</div>
<div class="col-md-6 col-sm-12">
<div class="d-flex justify-content-end">
<emergency-toggle-component
v-model="isEmergencyLocal"
@toggle-emergency="handleEmergencyToggle"
v-model="isEmergency"
:disabled="!isOpen"
/>
<span
class="badge text-bg-chill-green text-white"
style="font-size: 1rem"
v-if="isOpen"
>
{{ trans(CHILL_TICKET_TICKET_BANNER_OPEN) }}
</span>
<span
class="badge text-bg-chill-red text-white"
style="font-size: 1rem"
v-else
>
{{ trans(CHILL_TICKET_TICKET_BANNER_CLOSED) }}
</span>
<state-toggle-component v-model="isOpen" />
</div>
<div class="d-flex justify-content-end">
<p class="created-at-timespan" v-if="ticket.createdAt">
@@ -102,26 +92,33 @@
</style>
<script setup lang="ts">
import { ref, computed } from "vue";
import { computed, ref } from "vue";
import { useToast } from "vue-toast-notification";
// Components
import AddresseeComponent from "./Addressee/AddresseeComponent.vue";
import EmergencyToggleComponent from "./Emergency/EmergencyToggleComponent.vue";
import StateToggleComponent from "./State/StateToggleComponent.vue";
import PersonComponent from "./Person/PersonComponent.vue";
// Types
import { Ticket, TicketEmergencyState } from "../../../types";
import { Ticket } from "../../../types";
import { Person } from "ChillPersonAssets/types";
import {
trans,
CHILL_TICKET_TICKET_BANNER_OPEN,
CHILL_TICKET_TICKET_BANNER_CLOSED,
CHILL_TICKET_TICKET_BANNER_SINCE,
CHILL_TICKET_TICKET_BANNER_SPEAKER,
CHILL_TICKET_TICKET_BANNER_CALLER,
CHILL_TICKET_TICKET_BANNER_PERSON,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS,
CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR,
CHILL_TICKET_TICKET_BANNER_EMERGENCY_SUCCESS,
CHILL_TICKET_TICKET_BANNER_EMERGENCY_ERROR,
CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_ERROR,
CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_SUCCESS,
} from "translator";
// Store
@@ -142,22 +139,56 @@ setInterval(() => {
today.value = new Date();
}, 5000);
const isOpen = computed(() => store.getters.isOpen);
const isEmergencyLocal = computed(() => store.getters.isEmergency);
const isOpen = computed({
get: () => store.getters.isOpen as boolean,
set: async (value: boolean) => {
try {
await store.dispatch("setTicketState", value ? "open" : "close");
toast.success(
trans(
value
? CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS
: CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS,
),
);
} catch (error) {
console.error(error);
toast.error(
trans(
value
? CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR
: CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR,
),
);
}
},
});
const isEmergency = computed({
get: () => store.getters.isEmergency as boolean,
set: async (value: boolean) => {
try {
await store.dispatch("setEmergency", value ? "yes" : "no");
toast.success(
trans(
value
? CHILL_TICKET_TICKET_BANNER_EMERGENCY_SUCCESS
: CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_SUCCESS,
),
);
} catch (error) {
console.error(error);
toast.error(
trans(
value
? CHILL_TICKET_TICKET_BANNER_EMERGENCY_ERROR
: CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_ERROR,
),
);
}
},
});
const since = computed(() => {
return store.getters.getSinceCreated(today.value);
});
// Methods
function handleEmergencyToggle(emergency: TicketEmergencyState) {
store.dispatch("toggleEmergency", emergency).catch(({ name, violations }) => {
if (name === "ValidationException" || name === "AccessException") {
violations.forEach((violation: string) =>
toast.open({ message: violation }),
);
} else {
toast.open({ message: "An error occurred" });
}
});
}
</script>

View File

@@ -1,19 +1,103 @@
<template>
<div class="col-12">
<blockquote class="chill-user-quote">
<button
class="btn btn-sm btn-edit float-end"
title="Edit"
@click="editCommentModal = true"
style="top: 0.5rem; right: 0.5rem"
v-if="canBeEdited && isOpen"
/>
<p v-html="convertMarkdownToHtml(commentHistory.content)"></p>
</blockquote>
</div>
<Modal
v-if="editCommentModal"
:show="editCommentModal"
modal-dialog-class="modal-xl"
@close="editCommentModal = false"
>
<template #header>
<h5 class="modal-title">
{{ trans(CHILL_TICKET_TICKET_EDIT_COMMENT_TITLE) }}
</h5>
</template>
<template #body>
<comment-editor v-model="editedComment" />
</template>
<template #footer>
<button
class="btn btn-primary"
@click="restoreComment"
v-if="commentHistory.deleted"
>
{{ trans(RESTORE) }}
</button>
<button class="btn btn-delete" @click="deleteComment" v-else>
{{ trans(DELETE) }}
</button>
<button class="btn btn-save" @click="saveComment">
{{ trans(SAVE) }}
</button>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { useStore } from "vuex";
import { marked } from "marked";
import DOMPurify from "dompurify";
// Types
import { Comment } from "../../../../types";
defineProps<{ commentHistory: Comment }>();
// Components
import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue";
import Modal from "../../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue";
import {
trans,
SAVE,
DELETE,
RESTORE,
CHILL_TICKET_TICKET_EDIT_COMMENT_TITLE,
CHILL_TICKET_TICKET_EDIT_COMMENT_SUCCESS,
CHILL_TICKET_TICKET_DELETE_COMMENT_SUCCESS,
CHILL_TICKET_TICKET_RESTORE_COMMENT_SUCCESS,
} from "translator";
import { useToast } from "vue-toast-notification";
const props = defineProps<{ commentHistory: Comment }>();
const toast = useToast();
const store = useStore();
const isOpen = computed(() => store.getters.isOpen as boolean);
const canBeEdited = computed(() =>
store.getters["canBeEdited"](props.commentHistory),
);
const editCommentModal = ref<boolean>(false);
const editedComment = ref<string>(props.commentHistory.content);
const saveComment = () => {
store.dispatch("editComment", {
id: props.commentHistory.id,
content: editedComment.value,
});
editCommentModal.value = false;
toast.success(trans(CHILL_TICKET_TICKET_EDIT_COMMENT_SUCCESS));
};
const deleteComment = () => {
store.dispatch("deleteComment", props.commentHistory.id);
editCommentModal.value = false;
toast.success(trans(CHILL_TICKET_TICKET_DELETE_COMMENT_SUCCESS));
};
const restoreComment = () => {
store.dispatch("restoreComment", props.commentHistory.id);
editCommentModal.value = false;
toast.success(trans(CHILL_TICKET_TICKET_RESTORE_COMMENT_SUCCESS));
};
const preprocess = (markdown: string): string => {
return markdown;

View File

@@ -1,24 +1,92 @@
<template>
<div class="row">
<div class="col-12" v-if="motive">
<div class="input-group mb-2">
<input
type="text"
:value="localizeTranslatableString(motive.label)"
readonly
class="form-control"
/>
<div class="input-group-append">
<peloton-component :stored-objects="motive.storedObjects" />
</div>
</div>
</div>
<div class="col-12">
<comment-editor v-model="content" />
</div>
<div class="col-12" v-if="motive">
<div
class="input-group mb-2"
v-for="(supplementaryComments, index) in motive.supplementaryComments"
:key="index"
>
<div class="input-group-prepend d-flex align-items-center px-1">
<span class="badge rounded-pill bg-chill-red">
{{ supplementaryComments.label }}
</span>
</div>
<input
type="text"
v-model="supplementaryCommentsInput[index]"
class="form-control"
@keyup.enter="addSupplementaryComments(index)"
@keydown.enter.prevent
/>
<div class="input-group-append">
<button
class="input-group-text btn btn-submit"
type="button"
@click="addSupplementaryComments(index)"
>
<i class="fa fa-plus"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { reactive, ref, watch } from "vue";
// Components
import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue";
import PelotonComponent from "../PelotonComponent.vue";
// Types
import { Motive } from "../../../../types";
// Utils
import { localizeTranslatableString } from "../../utils/utils";
import { StoredObject } from "ChillDocStoreAssets/types";
const props = defineProps<{
modelValue?: string;
motive?: Motive;
}>();
const emit =
defineEmits<(e: "update:modelValue", value: string | undefined) => void>();
const supplementaryCommentsInput = reactive<string[]>([]);
const emit = defineEmits<{
"update:modelValue": [value: string | undefined];
"show-peloton-modal": [storedObjects: StoredObject[]];
}>();
const content = ref(props.modelValue);
function addSupplementaryComments(index: number) {
if (supplementaryCommentsInput[index]) {
const supplementaryText = `**${props.motive?.supplementaryComments[index].label}**: ${supplementaryCommentsInput[index]}`;
content.value = content.value
? content.value + "\n" + supplementaryText
: supplementaryText;
supplementaryCommentsInput[index] = "";
}
}
watch(content, (value) => {
emit("update:modelValue", value);
});

View File

@@ -3,36 +3,41 @@
<button
class="badge rounded-pill me-1"
:class="{
'bg-danger': modelValue,
'bg-chill-red': modelValue,
'bg-secondary': !modelValue,
'no-pointer': props.disabled,
}"
@click="toggleEmergency"
:disabled="props.disabled"
>
{{ trans(CHILL_TICKET_TICKET_BANNER_EMERGENCY) }}
</button>
</span>
</template>
<style scoped>
.no-pointer {
cursor: not-allowed !important;
}
</style>
<script lang="ts" setup>
import { trans, CHILL_TICKET_TICKET_BANNER_EMERGENCY } from "translator";
import { TicketEmergencyState } from "../../../../types";
// Props
const props = defineProps<{
modelValue: boolean;
disabled?: boolean;
}>();
// Emits
const emit = defineEmits<{
"update:modelValue": [value: boolean];
"toggle-emergency": [emergency: TicketEmergencyState];
}>();
// Methods
function toggleEmergency() {
const newValue = !props.modelValue;
emit("update:modelValue", newValue);
emit("toggle-emergency", newValue ? "yes" : "no");
}
</script>

View File

@@ -0,0 +1,262 @@
<template>
<button
class="input-group-text btn btn-primary"
type="button"
@click="handleClick"
:disabled="!storedObjects?.length"
>
<i class="fa fa-sitemap"></i>
</button>
<Modal
v-if="showPelotonsModal"
:show="showPelotonsModal"
modal-dialog-class="modal-xl"
@close="closeModal"
>
<template #header>
<div class="dropdown-container">
<select
v-model="selectedStoredObject"
class="form-select"
@change="onSelectionChange"
style="max-width: 400px"
>
<option
v-for="storedObject in storedObjects"
:key="storedObject.id"
:value="storedObject"
>
{{ storedObject.title }}
</option>
</select>
</div>
</template>
<template #body>
<div v-if="selectedStoredObject && documentUrl" class="document-viewer">
<div v-if="documentType === 'image'" class="image-container">
<img
:src="documentUrl"
class="img-fluid"
style="max-width: 100%; height: auto"
/>
</div>
<div v-else-if="documentType === 'pdf'" class="pdf-container">
<iframe
:src="documentUrl"
width="100%"
height="100%"
style="border: 1px solid #ccc"
></iframe>
<noscript>
<p>
{{ trans(CHILL_TICKET_PELOTON_IFRAME_NOT_SUPPORTED) }}
<a :href="documentUrl" target="_blank">
{{ trans(CHILL_TICKET_PELOTON_CLICK_TO_OPEN_PDF) }}
</a>
</p>
</noscript>
</div>
<div v-else class="unsupported-document">
<p>{{ trans(CHILL_TICKET_PELOTON_UNSUPPORTED_TYPE) }}</p>
<a :href="documentUrl" target="_blank" class="btn btn-primary">
{{ trans(CHILL_TICKET_PELOTON_OPEN_NEW_TAB) }}
</a>
</div>
</div>
<div v-if="isLoading" class="loading-indicator">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{
trans(CHILL_TICKET_PELOTON_LOADING)
}}</span>
</div>
<p>{{ trans(CHILL_TICKET_PELOTON_LOADING_DOCUMENT) }}</p>
</div>
<div v-if="error" class="alert alert-danger">
{{ error }}
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
// Component
import Modal from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue";
// Translations
import {
CHILL_TICKET_PELOTON_LOADING_DOCUMENT,
CHILL_TICKET_PELOTON_LOADING,
CHILL_TICKET_PELOTON_ERROR_LOADING,
CHILL_TICKET_PELOTON_ERROR_NOT_READY,
CHILL_TICKET_PELOTON_UNSUPPORTED_TYPE,
CHILL_TICKET_PELOTON_OPEN_NEW_TAB,
CHILL_TICKET_PELOTON_IFRAME_NOT_SUPPORTED,
CHILL_TICKET_PELOTON_CLICK_TO_OPEN_PDF,
trans,
} from "translator";
// Type
import { StoredObject } from "ChillDocStoreAssets/types";
// Utils
import {
download_and_decrypt_doc,
is_object_ready,
} from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpers";
const props = defineProps<{ storedObjects: StoredObject[] }>();
const selectedStoredObject = ref<StoredObject | null>(null);
const documentUrl = ref<string>("");
const documentType = ref<string>("");
const isLoading = ref<boolean>(false);
const error = ref<string>("");
const showPelotonsModal = ref<boolean>(false);
watch(
() => props.storedObjects,
(newStoredObjects) => {
if (
newStoredObjects &&
newStoredObjects.length > 0 &&
!selectedStoredObject.value
) {
selectedStoredObject.value = newStoredObjects[0];
onSelectionChange();
}
},
{ immediate: true },
);
async function onSelectionChange() {
if (!selectedStoredObject.value) {
cleanupPrevious();
documentType.value = "";
error.value = "";
return;
}
isLoading.value = true;
error.value = "";
cleanupPrevious();
try {
const document = await is_object_ready(selectedStoredObject.value);
if (!document) {
error.value = trans(CHILL_TICKET_PELOTON_ERROR_NOT_READY);
return;
}
const doc = await download_and_decrypt_doc(
selectedStoredObject.value,
selectedStoredObject.value.currentVersion,
);
const blob = new Blob([doc], { type: document.type });
documentUrl.value = URL.createObjectURL(blob);
if (document.type.startsWith("image/")) {
documentType.value = "image";
} else if (document.type === "application/pdf") {
documentType.value = "pdf";
} else {
documentType.value = "other";
}
} catch (err) {
console.error(trans(CHILL_TICKET_PELOTON_ERROR_LOADING), err);
error.value = trans(CHILL_TICKET_PELOTON_ERROR_LOADING);
} finally {
isLoading.value = false;
}
}
// Nettoyer les URLs blob précédentes
function cleanupPrevious() {
if (documentUrl.value) {
URL.revokeObjectURL(documentUrl.value);
documentUrl.value = "";
}
}
function handleClick() {
showPelotonsModal.value = true;
if (
!selectedStoredObject.value &&
props.storedObjects &&
props.storedObjects.length > 0
) {
selectedStoredObject.value = props.storedObjects[0];
onSelectionChange();
}
}
function closeModal() {
showPelotonsModal.value = false;
}
</script>
<style lang="scss" scoped>
.dropdown-container {
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
}
.document-viewer {
height: 80vh;
.image-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
img {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
}
}
.pdf-container {
height: 100%;
iframe {
border-radius: 0.25rem;
height: 100% !important;
}
}
.unsupported-document {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
p {
margin-bottom: 1rem;
color: #6c757d;
}
}
}
.loading-indicator {
text-align: center;
padding: 2rem;
.spinner-border {
margin-bottom: 1rem;
}
p {
color: #6c757d;
margin: 0;
}
}
</style>

View File

@@ -37,8 +37,10 @@
<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"
/>
@@ -59,6 +61,7 @@ import TicketListComponent from "../../TicketList/components/TicketListComponent
import {
trans,
CHILL_TICKET_LIST_NO_TICKETS,
CHILL_TICKET_LIST_ERROR_LOADING_TICKET,
CHILL_TICKET_TICKET_PREVIOUS_TICKETS,
CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS,
} from "translator";
@@ -69,12 +72,14 @@ import { TicketSimple } from "../../../types";
// Utils
import { localizedUrl } from "ChillMainAssets/lib/localizationHelper/localizationHelper";
import { Pagination } from "ChillMainAssets/lib/api/apiMethods";
const store = useStore();
const showPreviousTicketModal = ref(false);
const showTicketHistoryModal = ref(false);
const selectedTicketId = ref<number | null>(null);
const pagination = computed(() => store.getters.getPagination as Pagination);
const currentPersons = computed(
() => store.getters.getCurrentPersons as Person[],
);
@@ -84,11 +89,13 @@ const previousTickets = computed(
onMounted(async () => {
try {
await store.dispatch("fetchTicketList", {
byPerson: currentPersons.value.map((person) => person.id),
});
if (currentPersons.value.length) {
await store.dispatch("fetchTicketList", {
byPerson: currentPersons.value.map((person) => person.id),
});
}
} catch (error) {
console.error("Erreur lors du chargement des tickets:", error);
console.error(trans(CHILL_TICKET_LIST_ERROR_LOADING_TICKET), error);
}
});
@@ -107,10 +114,16 @@ async function handleViewTicket(ticketId: number) {
try {
await store.dispatch("fetchTicket", ticketId);
} catch (error) {
console.error("Erreur lors du chargement du ticket:", error);
console.error(trans(CHILL_TICKET_LIST_ERROR_LOADING_TICKET), error);
}
}
const fetchNextPage = async () => {
if (pagination.value.next) {
await store.dispatch("fetchTicketListByUrl", pagination.value.next);
}
};
function handleEditTicket(ticketId: number) {
const returnPath = localizedUrl(`/ticket/ticket/list`);
window.location.href = localizedUrl(

View File

@@ -0,0 +1,68 @@
<template>
<span class="d-block d-sm-inline-block ms-sm-3 ms-md-0">
<button
class="badge rounded-pill me-1"
:class="{
'bg-chill-green': modelValue,
'bg-chill-red': !modelValue,
}"
@click="toggleEmergency"
>
{{
modelValue
? trans(CHILL_TICKET_TICKET_BANNER_OPEN)
: trans(CHILL_TICKET_TICKET_BANNER_CLOSED)
}}
</button>
</span>
</template>
<script lang="ts" setup>
import {
trans,
CHILL_TICKET_TICKET_BANNER_OPEN,
CHILL_TICKET_TICKET_BANNER_CLOSED,
} from "translator";
// Props
const props = defineProps<{
modelValue: boolean;
}>();
// Emits
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
// Methods
function toggleEmergency() {
const newValue = !props.modelValue;
emit("update:modelValue", newValue);
}
</script>
<style lang="scss" scoped>
a.flag-toggle {
color: white;
cursor: pointer;
&:hover {
color: white;
text-decoration: underline;
border-radius: 20px;
}
i {
margin: auto 0.4em;
}
span.on {
font-weight: bolder;
}
}
button.badge {
&.bg-secondary {
opacity: 0.5;
&:hover {
opacity: 0.7;
}
}
}
</style>

View File

@@ -0,0 +1,153 @@
<template>
<div class="card mb-4">
<div class="card-body">
<form @submit.prevent="submitForm">
<!-- Sélection du motif -->
<div class="mb-3">
<label class="form-label">{{
trans(CHILL_TICKET_TICKET_SET_MOTIVE_TITLE)
}}</label>
<motive-selector-component
v-model="ticketForm.motive"
:motives="motives"
/>
</div>
<!-- Sélection des personnes -->
<div class="row mb-3">
<div class="col-md-6">
<label class="form-label">
{{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER) }}
</label>
<persons-selector-component
v-model="ticketForm.caller"
:suggested="[]"
:multiple="false"
:types="['person', 'thirdparty']"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL)"
/>
</div>
<div class="col-md-6">
<label class="form-label">
{{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON) }}
</label>
<persons-selector-component
v-model="ticketForm.persons"
:suggested="suggestedPersons"
:multiple="true"
:types="['person']"
:label="trans(CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL)"
/>
</div>
</div>
<!-- Éditeur de commentaire -->
<div class="mb-3">
<label class="form-label">{{
trans(CHILL_TICKET_TICKET_ADD_COMMENT_TITLE)
}}</label>
<comment-editor-component
v-model="ticketForm.content"
:motive="ticketForm.motive"
/>
</div>
<!-- Boutons d'action -->
<div class="d-flex justify-content-end gap-2 mt-4">
<button type="button" class="btn btn-secondary" @click="resetForm">
{{ trans(CHILL_TICKET_TICKET_INIT_FORM_RESET) }}
</button>
<button type="submit" class="btn btn-primary">
{{ trans(CHILL_TICKET_TICKET_INIT_FORM_SUBMIT) }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import { useStore } from "vuex";
// Components
import MotiveSelectorComponent from "./Motive/MotiveSelectorComponent.vue";
import CommentEditorComponent from "./Comment/CommentEditorComponent.vue";
import PersonsSelectorComponent from "./Person/PersonsSelectorComponent.vue";
// Types
import { Motive, TicketInitForm } from "../../../types";
import { Person } from "ChillPersonAssets/types";
// Translations
import {
trans,
CHILL_TICKET_TICKET_INIT_FORM_SUBMIT,
CHILL_TICKET_TICKET_INIT_FORM_RESET,
CHILL_TICKET_TICKET_SET_MOTIVE_TITLE,
CHILL_TICKET_TICKET_ADD_COMMENT_TITLE,
CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER,
CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON,
CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL,
CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL,
} from "translator";
defineProps<{
motives: Motive[];
suggestedPersons: Person[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: TicketInitForm];
submit: [formData: TicketInitForm];
}>();
const store = useStore();
const ticketForm = reactive({
content: "",
motive: undefined as Motive | undefined,
persons: [] as Person[],
caller: null as Person | null,
} as TicketInitForm);
watch(
() => ticketForm.caller,
async (newCaller) => {
await store.dispatch("setCaller", newCaller);
await store.dispatch("getSuggestedPersons");
},
);
function submitForm() {
emit("submit", {
content: ticketForm.content,
motive: ticketForm.motive,
persons: [...ticketForm.persons],
caller: ticketForm.caller,
});
}
function resetForm() {
ticketForm.content = "";
ticketForm.motive = undefined;
ticketForm.persons = [];
ticketForm.caller = null;
}
</script>
<style lang="scss" scoped>
.card {
border: 1px solid #dee2e6;
border-radius: 0.375rem;
}
.card-header {
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
}
.gap-2 {
gap: 0.5rem;
}
</style>

View File

@@ -8,6 +8,7 @@ import {
State as TicketListState,
moduleTicketList,
} from "./modules/ticket_list";
import { State as UserState, moduleUser } from "./modules/user";
export interface RootState {
motive: MotiveStates;
@@ -16,6 +17,7 @@ export interface RootState {
addressee: AddresseeStates;
persons: PersonsState;
ticketList: TicketListState;
user: UserState;
}
export const store = createStore<RootState>({
@@ -26,5 +28,6 @@ export const store = createStore<RootState>({
addressee: moduleAddressee,
persons: modulePersons,
ticketList: moduleTicketList,
user: moduleUser,
},
});

View File

@@ -61,15 +61,11 @@ export const moduleAddressee: Module<State, RootState> = {
}
},
async setAdressees(
{ commit },
datas: { ticketId: number; addressees: UserGroupOrUser[] },
) {
const { ticketId, addressees } = datas;
async setAddressees({ commit, rootState }, addressees: UserGroupOrUser[]) {
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/addressees/set`,
`/api/1.0/ticket/${rootState.ticket.ticket.id}/addressees/set`,
{
addressees: addressees.map((addressee) => {
return { id: addressee.id, type: addressee.type };

View File

@@ -3,7 +3,7 @@ import { makeFetch } from "../../../../../../../../ChillMainBundle/Resources/pub
import { Module } from "vuex";
import { RootState } from "..";
import { Comment } from "../../../../types";
import { Comment, Ticket } from "../../../../types";
import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types";
export interface State {
@@ -14,18 +14,32 @@ export const moduleComment: Module<State, RootState> = {
state: () => ({
comments: [] as Comment[],
}),
getters: {},
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) => {
return (
comment.createdBy?.username === rootState.user.currentUser?.username
);
},
},
mutations: {},
actions: {
async createComment(
{ commit },
datas: { ticketId: number; content: Comment["content"] },
) {
const { ticketId, content } = datas;
async createComment({ commit, rootState }, content: Comment["content"]) {
try {
const result = await makeFetch(
const result: Ticket = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/comment/add`,
`/api/1.0/ticket/${rootState.ticket.ticket.id}/comment/add`,
{ content },
);
commit("setTicket", result);
@@ -34,5 +48,46 @@ export const moduleComment: Module<State, RootState> = {
throw error.name;
}
},
async editComment(
{ commit },
{ id, content }: { id: Comment["id"]; content: Comment["content"] },
) {
try {
const result: Comment = await makeFetch(
"POST",
`/api/1.0/ticket/comment/${id}/edit`,
{ content },
);
commit("setTicketHistoryComment", result);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
async deleteComment({ commit }, id: Comment["id"]) {
try {
const result: Comment = await makeFetch(
"POST",
`/api/1.0/ticket/comment/${id}/delete`,
);
commit("setTicketHistoryComment", result);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
async restoreComment({ commit }, id: Comment["id"]) {
try {
const result: Comment = await makeFetch(
"POST",
`/api/1.0/ticket/comment/${id}/restore`,
);
commit("setTicketHistoryComment", result);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
},
};

View File

@@ -40,15 +40,11 @@ export const moduleMotive: Module<State, RootState> = {
}
},
async createMotive(
{ commit },
datas: { ticketId: number; motive: Motive },
) {
const { ticketId, motive } = datas;
async createMotive({ commit, rootState }, motive: Motive) {
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/motive/set`,
`/api/1.0/ticket/${rootState.ticket.ticket.id}/motive/set`,
{
motive: {
id: motive.id,

View File

@@ -24,19 +24,17 @@ export const modulePersons: Module<State, RootState> = {
},
},
actions: {
async setPersons(
{ commit, rootState: RootState },
payload: { persons: Person[] },
) {
const persons = payload.persons.map((person: Person) => ({
async setPersons({ commit, rootState }, persons: Person[]) {
const personData = persons.map((person: Person) => ({
id: person.id,
type: person.type,
}));
console.log("Setting persons:", personData);
try {
const result: Ticket = await makeFetch(
"POST",
`/api/1.0/ticket/${RootState.ticket.ticket.id}/persons/set`,
{ persons },
`/api/1.0/ticket/${rootState.ticket.ticket.id}/persons/set`,
{ persons: personData },
);
commit("setTicket", result);
@@ -46,21 +44,18 @@ export const modulePersons: Module<State, RootState> = {
throw error.name;
}
},
async setCaller(
{ commit, rootState: RootState },
payload: { caller: Person | null },
) {
async setCaller({ commit, rootState }, caller: Person | null) {
try {
const caller = payload.caller
const callerData = caller
? {
id: payload.caller.id,
type: payload.caller.type,
id: caller.id,
type: caller.type,
}
: null;
const result: Ticket = await makeFetch(
"POST",
`/api/1.0/ticket/ticket/${RootState.ticket.ticket.id}/set-caller`,
{ caller },
`/api/1.0/ticket/ticket/${rootState.ticket.ticket.id}/set-caller`,
{ caller: callerData },
);
commit("setTicket", result as Ticket);
} catch (e: unknown) {
@@ -69,9 +64,9 @@ export const modulePersons: Module<State, RootState> = {
}
},
async getSuggestedPersons({ commit, rootState: RootState }) {
async getSuggestedPersons({ commit, rootState }) {
try {
const ticketId = RootState.ticket.ticket.id;
const ticketId = rootState.ticket.ticket.id;
const result: Person[] = await makeFetch(
"GET",
`/api/1.0/ticket/ticket/${ticketId}/suggest-person`,

View File

@@ -1,7 +1,13 @@
import { Module } from "vuex";
import { RootState } from "..";
import { Ticket, TicketEmergencyState } from "../../../../types";
import {
Comment,
Ticket,
TicketEmergencyState,
TicketHistoryLine,
TicketState,
} from "../../../../types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types";
import { getSinceCreated } from "../../utils/utils";
@@ -30,7 +36,10 @@ export const moduleTicket: Module<State, RootState> = {
return state.ticket.currentState === "open";
},
isEmergency(state) {
return state.ticket.emergency == "yes" ? true : false;
return state.ticket.emergency == "yes";
},
isNewTicket(state) {
return state.ticket.history.length == 3;
},
getTicket(state) {
state.ticket.history = state.ticket.history.sort((a, b) =>
@@ -59,13 +68,24 @@ export const moduleTicket: Module<State, RootState> = {
setTicket(state, ticket: Ticket) {
state.ticket = ticket;
},
setTicketHistoryComment(state, ticketHistoryData: Comment) {
const index = state.ticket.history.findIndex(
(item: TicketHistoryLine) =>
item.event_type == "add_comment" &&
item.data.id === ticketHistoryData.id,
);
if (index !== -1) {
state.ticket.history[index].data = ticketHistoryData;
}
},
},
actions: {
async closeTicket({ commit, state }) {
async setTicketState({ commit, state }, ticketState: TicketState) {
try {
const result: Ticket = await makeFetch(
"POST",
`/api/1.0/ticket/ticket/${state.ticket.id}/close`,
`/api/1.0/ticket/ticket/${state.ticket.id}/${ticketState}`,
);
commit("setTicket", result as Ticket);
} catch (e: unknown) {
@@ -73,19 +93,23 @@ export const moduleTicket: Module<State, RootState> = {
throw error.name;
}
},
async reopenTicket({ commit, state }) {
async fetchTicket({ commit }, ticketId: number) {
try {
const result: Ticket = await makeFetch(
"POST",
`/api/1.0/ticket/ticket/${state.ticket.id}/open`,
const ticket: Ticket = await makeFetch(
"GET",
`/api/1.0/ticket/ticket/${ticketId}`,
);
commit("setTicket", result as Ticket);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
commit("setTicket", ticket);
} catch (error) {
console.error(
"Erreur lors du chargement du ticket:",
error as ApiException,
);
throw error;
}
},
async toggleEmergency({ commit, state }, emergency: TicketEmergencyState) {
async setEmergency({ commit, state }, emergency: TicketEmergencyState) {
try {
const result: Ticket = await makeFetch(
"POST",

View File

@@ -1,7 +1,7 @@
import { Module } from "vuex";
import { RootState } from "..";
import { Ticket, TicketFilterParams, TicketSimple } from "../../../../types";
import { TicketFilterParams, TicketSimple } from "../../../../types";
import {
makeFetch,
Pagination,
@@ -14,7 +14,6 @@ import {
export interface State {
ticket_list: TicketSimple[];
ticket_details: Ticket | null;
pagination: Pagination;
count: number;
user: User;
@@ -23,7 +22,6 @@ export interface State {
export const moduleTicketList: Module<State, RootState> = {
state: () => ({
ticket_list: [],
ticket_details: null,
pagination: {
first: 0,
items_per_page: 50,
@@ -38,9 +36,6 @@ export const moduleTicketList: Module<State, RootState> = {
getTicketList(state): TicketSimple[] {
return state.ticket_list;
},
getTicketDetails(state): Ticket | null {
return state.ticket_details;
},
getPagination(state) {
return state.pagination;
},
@@ -55,9 +50,6 @@ export const moduleTicketList: Module<State, RootState> = {
setTicketList(state, ticketList: TicketSimple[]) {
state.ticket_list = ticketList;
},
setTicketDetails(state, ticket: Ticket | null) {
state.ticket_details = ticket;
},
setPagination(state, pagination: State["pagination"]) {
state.pagination = pagination;
},
@@ -69,11 +61,26 @@ export const moduleTicketList: Module<State, RootState> = {
},
},
actions: {
async fetchTicketList({ commit }, ticketFilterParams: TicketFilterParams) {
async fetchTicketList(
{ commit },
ticketFilterParams: TicketFilterParams | null,
) {
try {
const params = new URLSearchParams(
ticketFilterParams as Record<string, string>,
).toString();
let params = "";
if (ticketFilterParams) {
const filteredParams = Object.fromEntries(
Object.entries(ticketFilterParams).filter(
([, value]) =>
value !== undefined &&
value !== null &&
value !== "" &&
value.length > 0,
),
);
params = new URLSearchParams(
filteredParams as Record<string, string>,
).toString();
}
const { results, pagination, count } = (await makeFetch(
"GET",
`/api/1.0/ticket/ticket/list/?${params}`,
@@ -94,7 +101,7 @@ export const moduleTicketList: Module<State, RootState> = {
try {
const user = await makeFetch(
"GET",
"http://localhost:8000/api/1.0/main/whoami.json",
"/api/1.0/main/whoami.json",
);
commit("setUser", user);
return user;
@@ -106,21 +113,6 @@ export const moduleTicketList: Module<State, RootState> = {
throw error;
}
},
async fetchTicketDetails({ commit }, ticketId: number) {
try {
const ticket: Ticket = await makeFetch(
"GET",
`/api/1.0/ticket/ticket/${ticketId}`,
);
commit("setTicketDetails", ticket);
} catch (error) {
console.error(
"Erreur lors du chargement du ticket:",
error as ApiException,
);
throw error;
}
},
async fetchTicketListByUrl({ commit, state }, url: string) {
try {
const { results, pagination, count } = (await makeFetch(

View File

@@ -0,0 +1,41 @@
import { Module } from "vuex";
import { RootState } from "../index";
import { ApiException, User } from "ChillMainAssets/types";
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
export interface State {
currentUser: User | null;
}
export const moduleUser: Module<State, RootState> = {
state: () => ({
currentUser: null,
}),
getters: {
currentUser(state) {
return state.currentUser;
},
},
mutations: {
setCurrentUser(state, user: User) {
state.currentUser = user;
},
},
actions: {
async getCurrentUser({ commit }) {
try {
const userData: User = await makeFetch(
"GET",
"/api/1.0/main/whoami.json",
);
commit("setCurrentUser", userData);
} catch (e: unknown) {
const error = e as ApiException;
throw error.name;
}
},
},
};

View File

@@ -49,7 +49,7 @@ import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
// Types
import { TicketSimple, Motive } from "../../types";
import { TicketSimple, Motive, TicketFilterParams } from "../../types";
import type { Person } from "ChillPersonAssets/types";
// Components
@@ -60,15 +60,6 @@ import { Pagination } from "ChillMainAssets/lib/api/apiMethods";
// Translations
import { trans, CHILL_TICKET_LIST_LOADING_TICKET } from "translator";
interface TicketFilterParams {
byPerson?: number[];
byCurrentState?: string[];
byCurrentStateEmergency?: string[];
byMotives?: number[];
byCreatedAfter?: string;
byCreatedBefore?: string;
byResponseTimeExceeded?: string;
}
const store = useStore();
const title = window.title;
@@ -91,7 +82,6 @@ const handleFiltersChanged = async (filters: TicketFilterParams) => {
};
const fetchNextPage = async () => {
console.log("Fetching next page...");
if (pagination.value.next) {
await store.dispatch("fetchTicketListByUrl", pagination.value.next);
}
@@ -99,11 +89,18 @@ const fetchNextPage = async () => {
onMounted(async () => {
isLoading.value = true;
const filters: TicketFilterParams = {
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: "",
byAddresseeToMe: false,
};
try {
await Promise.all([
store.dispatch("fetchTicketList"),
store.dispatch("fetchMotives"),
]);
await store.dispatch("getCurrentUser");
await store.dispatch("fetchTicketList", filters);
await store.dispatch("fetchMotives");
} finally {
isLoading.value = false;
}

View File

@@ -10,7 +10,7 @@
<div class="row">
<!-- Filtre par usagé -->
<div class="col-md-6 mb-3">
<label class="form-label">{{
<label class="form-label" for="personSelector">{{
trans(CHILL_TICKET_LIST_FILTER_PERSONS_CONCERNED)
}}</label>
<persons-selector
@@ -18,6 +18,7 @@
:suggested="availablePersons"
:multiple="true"
:types="['person']"
id="personSelector"
:label="trans(CHILL_TICKET_LIST_FILTER_BY_PERSON)"
/>
</div>
@@ -25,12 +26,13 @@
<div class="col-md-6">
<!-- Filtre par motifs -->
<div class="row">
<label class="form-label">{{
<label class="form-label" for="motiveSelector">{{
trans(CHILL_TICKET_LIST_FILTER_BY_MOTIVES)
}}</label>
<motive-selector
v-model="selectedMotive"
:motives="availableMotives"
id="motiveSelector"
/>
<div class="mt-1" style="min-height: 2.2em">
@@ -54,56 +56,40 @@
<!-- Filtre par état actuel -->
<div class="row">
<div class="col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="open"
v-model="filters.byCurrentState"
id="stateOpen"
/>
<label class="form-check-label" for="stateOpen">{{
trans(CHILL_TICKET_LIST_FILTER_OPEN)
}}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="closed"
v-model="filters.byCurrentState"
id="stateClosed"
/>
<label class="form-check-label" for="stateClosed">
{{ trans(CHILL_TICKET_LIST_FILTER_CLOSED) }}
<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>
<!-- Filtre par état d'urgence -->
<div class="col-6">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="yes"
v-model="filters.byCurrentStateEmergency"
id="emergencyYes"
<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-chill-red',
off: 'bg-secondary',
}"
@update:model-value="handleEmergencyToggle"
id="emergency"
/>
<label class="form-check-label" for="emergencyYes">{{
trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)
}}</label>
</div>
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
value="no"
v-model="filters.byCurrentStateEmergency"
id="emergencyNo"
/>
<label class="form-check-label" for="emergencyNo">{{
trans(CHILL_TICKET_LIST_FILTER_NO_EMERGENCY)
}}</label>
</div>
</div>
</div>
@@ -135,7 +121,7 @@
<div class="d-flex gap-3">
<div class="form-check">
<input
v-model="filters.byMyTickets"
v-model="filters.byAddresseeToMe"
class="form-check-input"
type="checkbox"
id="stateMe"
@@ -159,6 +145,7 @@
id="byCreatedAfter"
v-model="filters.byCreatedAfter"
class="form-control"
:disabled="filters.byResponseTimeExceeded"
/>
</div>
<div class="col-md-6 mb-3">
@@ -205,7 +192,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { ref, computed, watch } from "vue";
import type { Person } from "ChillPersonAssets/types";
import type { Motive, TicketFilterParams, TicketFilters } from "../../../types";
@@ -221,7 +208,6 @@ import {
CHILL_TICKET_LIST_FILTER_CLOSED,
CHILL_TICKET_LIST_FILTER_TO_ME,
CHILL_TICKET_LIST_FILTER_EMERGENCY,
CHILL_TICKET_LIST_FILTER_NO_EMERGENCY,
CHILL_TICKET_LIST_FILTER_CREATED_AFTER,
CHILL_TICKET_LIST_FILTER_CREATED_BEFORE,
CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_EXCEEDED,
@@ -229,11 +215,13 @@ import {
CHILL_TICKET_LIST_FILTER_RESET,
CHILL_TICKET_LIST_FILTER_APPLY,
CHILL_TICKET_LIST_FILTER_RESULT,
CHILL_TICKET_LIST_FILTER_CURRENT_STATE,
} from "translator";
// Components
import PersonsSelector from "../../TicketApp/components/Person/PersonsSelectorComponent.vue";
import MotiveSelector from "../../TicketApp/components/Motive/MotiveSelectorComponent.vue";
import ToggleComponent from "./ToggleComponent.vue";
// Props
const props = defineProps<{
@@ -249,12 +237,12 @@ const emit = defineEmits<{
// État réactif
const filters = ref<TicketFilters>({
byCurrentState: [],
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: false,
byMyTickets: false,
byAddresseeToMe: false,
});
// Sélection des personnes
@@ -283,6 +271,27 @@ const selectedMotiveIds = computed(() =>
selectedMotives.value.map((motive) => motive.id),
);
// Nouveaux états pour les toggles
const isClosedToggled = ref(false);
const isEmergencyToggled = ref(false);
// Méthodes pour gérer les toggles
const handleStateToggle = (value: boolean) => {
if (value) {
filters.value.byCurrentState = ["closed"];
} else {
filters.value.byCurrentState = ["open"];
}
};
const handleEmergencyToggle = (value: boolean) => {
if (value) {
filters.value.byCurrentStateEmergency = ["yes"];
} else {
filters.value.byCurrentStateEmergency = ["no"];
}
};
// Méthodes
const formatDateToISO = (dateString: string): string => {
if (!dateString) return dateString;
@@ -340,8 +349,8 @@ const applyFilters = (): void => {
if (filters.value.byResponseTimeExceeded) {
apiFilters.byResponseTimeExceeded = "true";
}
if (filters.value.byMyTickets) {
apiFilters.byMyTickets = true;
if (filters.value.byAddresseeToMe) {
apiFilters.byAddresseeToMe = true;
}
emit("filters-changed", apiFilters);
@@ -349,31 +358,28 @@ const applyFilters = (): void => {
const resetFilters = (): void => {
filters.value = {
byCurrentState: [],
byCurrentState: ["open"],
byCurrentStateEmergency: [],
byCreatedAfter: "",
byCreatedBefore: "",
byResponseTimeExceeded: false,
byMyTickets: false,
byAddresseeToMe: false,
};
selectedPersons.value = [];
selectedMotives.value = [];
selectedMotive.value = undefined;
isClosedToggled.value = true;
isEmergencyToggled.value = false;
applyFilters();
};
const handleResponseTimeExceededChange = (): void => {
if (filters.value.byResponseTimeExceeded) {
filters.value.byCurrentState = [];
filters.value.byCreatedBefore = "";
filters.value.byCreatedAfter = "";
isClosedToggled.value = true;
}
};
// Charger les données disponibles si nécessaire
onMounted(() => {
// Ici vous pourriez faire des appels API pour charger les personnes et motifs disponibles
// si ils ne sont pas fournis en props
});
</script>
<style scoped>

View File

@@ -13,6 +13,15 @@
<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_DELETED) }}
</span>
<state-component
:new_state="history_line.data.new_state"
v-if="history_line.event_type == 'state_change'"
@@ -24,9 +33,7 @@
</div>
<div>
<span class="badge-user">
<user-render-box-badge
:user="history_line.by"
></user-render-box-badge>
<user-render-box-badge :user="history_line.by" />
</span>
<span class="fst-italic mx-2">
{{ formatDate(history_line.at) }}
@@ -60,7 +67,10 @@
/>
<comment-component
:commentHistory="history_line.data"
v-else-if="history_line.event_type == 'add_comment'"
v-else-if="
history_line.event_type == 'add_comment' &&
canBeDisplayed(history_line.data)
"
/>
<addressee-component
:addressees="history_line.data.addressees"
@@ -103,6 +113,7 @@ import {
CHILL_TICKET_TICKET_HISTORY_STATE_CHANGE,
CHILL_TICKET_TICKET_HISTORY_EMERGENCY_CHANGE,
CHILL_TICKET_TICKET_HISTORY_SET_CALLER,
CHILL_TICKET_TICKET_HISTORY_DELETED,
} from "translator";
const props = defineProps<{ history?: TicketHistoryLine[] }>();
@@ -110,6 +121,7 @@ const history = props.history ?? [];
const store = useStore();
const actionIcons = ref(store.getters.getActionIcons);
const canBeDisplayed = ref(store.getters["canBeDisplayed"]);
function explainSentence(history: TicketHistoryLine): string {
switch (history.event_type) {

View File

@@ -107,9 +107,7 @@ const emit = defineEmits<{
const store = useStore();
const selectedTicketId = ref<number | null>(null);
const showTicketHistoryModal = ref(false);
const ticketDetails = computed(
() => store.getters.getTicketDetails as Ticket | null,
);
const ticketDetails = computed(() => store.getters.getTicket as Ticket | null);
function closeHistoryModal() {
showTicketHistoryModal.value = false;
@@ -117,7 +115,7 @@ function closeHistoryModal() {
}
async function handleViewTicket(ticketId: number) {
await store.dispatch("fetchTicketDetails", ticketId);
await store.dispatch("fetchTicket", ticketId);
selectedTicketId.value = ticketId;
showTicketHistoryModal.value = true;

View File

@@ -25,7 +25,6 @@
</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"

View File

@@ -0,0 +1,156 @@
<template>
<div class="toggle-wrapper">
<input
:id="inputId"
type="checkbox"
class="toggle-input"
:checked="modelValue"
@change="handleChange"
:disabled="disabled"
/>
<label
:for="inputId"
class="toggle-label"
:class="[
modelValue
? classColor?.on || 'bg-chill-green'
: classColor?.off || 'bg-chill-red',
]"
:style="{
backgroundColor: classColor
? undefined
: !modelValue
? colorOff
: colorOn,
height: '28px',
width: toggleWidth + 'px',
}"
>
<span
class="toggle-slider"
:style="{
transform: modelValue ? `translateX(${toggleWidth - 26}px)` : 'none',
width: '24px',
height: '24px',
}"
>
</span>
<span
class="toggle-text"
:style="{
color: 'white',
marginLeft: !modelValue ? '28px' : '0',
paddingLeft: modelValue ? '6px' : '0',
}"
>
{{ modelValue ? onLabel : offLabel }}
</span>
</label>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
interface Props {
modelValue: boolean;
onLabel?: string;
offLabel?: string;
disabled?: boolean;
id?: string;
colorOn?: string;
colorOff?: string;
classColor?: {
on: string;
off: string;
};
}
type Emits = (e: "update:modelValue", value: boolean) => void;
const props = withDefaults(defineProps<Props>(), {
onLabel: "ON",
offLabel: "OFF",
disabled: false,
id: undefined,
colorOn: "#4caf50",
colorOff: "#ccc",
classColor: () => ({
on: "bg-chill-green",
off: "bg-chill-red",
}),
});
const emit = defineEmits<Emits>();
const inputId = computed(
() => props.id || `toggle-${Math.random().toString(36).substr(2, 9)}`,
);
// Calcule la largeur du toggle basée sur le label le plus long
const toggleWidth = computed(() => {
const onLength = props.onLabel.length;
const offLength = props.offLabel.length;
const maxLength = Math.max(onLength, offLength);
// Largeur minimale: 56px, puis 7px par caractère supplémentaire au-delà de 3 caractères
const baseWidth = 56;
const extraWidth = Math.max(0, maxLength - 3) * 7;
return baseWidth + extraWidth;
});
const handleChange = (event: Event) => {
const target = event.target as HTMLInputElement;
emit("update:modelValue", target.checked);
};
</script>
<style scoped>
.toggle-wrapper {
display: inline-block;
position: relative;
}
.toggle-input {
display: none;
}
.toggle-label {
display: block;
border-radius: 14px;
position: relative;
cursor: pointer;
transition: background-color 0.3s ease;
user-select: none;
}
.toggle-label:hover {
filter: brightness(0.95);
}
.toggle-input:disabled + .toggle-label {
opacity: 0.6;
cursor: not-allowed;
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
background-color: white;
border-radius: 50%;
transition: transform 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-text {
font-size: 9px;
font-weight: bold;
letter-spacing: 0.5px;
transition: opacity 0.3s ease;
}
</style>

View File

@@ -1,6 +1,8 @@
import App from "./App.vue";
import { createApp } from "vue";
import { store } from "../TicketApp/store";
import VueToast from "vue-toast-notification";
import "vue-toast-notification/dist/theme-sugar.css";
declare global {
interface Window {
@@ -12,4 +14,4 @@ const _app = createApp({
template: "<app></app>",
});
_app.component("app", App).use(store).mount("#ticketList");
_app.component("app", App).use(store).use(VueToast).mount("#ticketList");

View File

@@ -1,3 +1,4 @@
restore: Restaurer
chill_ticket:
list:
title: Tickets
@@ -5,6 +6,7 @@ chill_ticket:
no_tickets: "Aucun ticket"
loading_ticket: "Chargement des tickets..."
loading_ticket_details: "Chargement de l'historique du ticket..."
error_loading_ticket: "Erreur lors du chargement des ticket"
load_more: "Voir plus..."
addressees: "Attribué à"
persons: "Usager concernés"
@@ -15,7 +17,7 @@ chill_ticket:
created_between: Créés entre
state_change: État actuel
title: "Filtres des tickets"
persons_concerned: "Usager concernés"
persons_concerned: "Usagers concernés"
by_person: "Par personne"
by_motives: "Par motifs"
current_state: "État actuel"
@@ -26,13 +28,19 @@ chill_ticket:
created_after: "Créé après"
created_before: "Créé avant"
response_time_exceeded: "Temps de réponse dépassé"
response_time_warning: 'Attention : Ce filtre supprime automatiquement les filtres "État actuel" et "Créé avant"'
response_time_warning: 'Attention : ce filtre désactive les filtres "par dates" et affiche uniquement les tickets ouverts'
reset: "Réinitialiser"
apply: "Appliquer les filtres"
remove: "Supprimer"
result: "{count, plural, =0 {Aucun résultat} =1 {resultat} other {resultats}}"
ticket:
init_form:
title: "Ajouter des informations sur le ticket"
submit: "Mettre à jour le ticket"
reset: "Réinitialiser"
success: "Ticket mis à jour avec succès"
error: "Veuillez remplir tous les champs obligatoires"
history:
add_comment: "Nouveau commentaire"
addressees_state: "Attributions"
@@ -42,6 +50,7 @@ chill_ticket:
create_ticket: "Ticket créé"
state_change: ""
emergency_change: ""
deleted: "Supprimé"
previous_tickets: "Précédents tickets"
actions_toolbar:
cancel: "Annuler"
@@ -52,6 +61,13 @@ chill_ticket:
reopen: "Rouvrir"
reopen_success: "Rouverture du ticket réussie"
reopen_error: "Erreur lors de la rouverture du ticket"
restore_comment:
success: "Commentaire restauré"
delete_comment:
success: "Commentaire supprimé"
edit_comment:
title: "Éditer le commentaire"
success: "Commentaire modifié"
add_comment:
title: "Commentaire"
label: "Ajouter un commentaire"
@@ -78,8 +94,8 @@ chill_ticket:
success: "Appelants et usagers mis à jour"
error: "Aucun usager sélectionné"
banner:
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 à}}"
person: "{count, plural, =0 {Aucun usager impliqué} =1 {Usager impliqué} other {Usagers impliqués}}"
speaker: "{count, plural, =0 {Aucun intervenant} =1 {Intervenant attribué} other {Intervenants attribués}}"
caller: "{count, plural, =0 {Aucun appelant} =1 {Appelant} other {Appelants}}"
open: "Ouvert"
closed: "Fermé"
@@ -87,27 +103,40 @@ chill_ticket:
and: "et"
days: >-
{count, plural,
=0 {aucun jour}
=1 {1 jour}
other {# jours}
=0 {aucun jour}
=1 {1 jour}
other {# jours}
}
hours: >-
{count, plural,
=0 {aucune heure}
=1 {1 heure}
other {# heures}
=0 {aucune heure}
=1 {1 heure}
other {# heures}
}
minutes: >-
{count, plural,
=0 {aucune minute}
=1 {1 minute}
other {# minutes}
=0 {aucune minute}
=1 {1 minute}
other {# minutes}
}
seconds: >-
{count, plural,
=0 {aucune seconde}
=1 {1 seconde}
other {# secondes}
=0 {aucune seconde}
=1 {1 seconde}
other {# secondes}
}
no_motive: "Pas de motif"
no_motive: "Aucun motif"
emergency: "URGENT"
emergency_success: "Ticket marqué comme urgent"
emergency_error: "Erreur lors de la tentative de marquage du ticket comme urgent"
no_emergency_success: "Ticket marqué comme non urgent"
no_emergency_error: "Erreur lors de la tentative de marquage du ticket comme non urgent"
peloton:
loading: "Chargement..."
loading_document: "Chargement du document..."
error_loading: "Erreur lors du chargement du document."
error_not_ready: "Le document n'est pas prêt ou accessible."
unsupported_type: "Type de document non supporté pour l'affichage."
open_new_tab: "Ouvrir dans un nouvel onglet"
iframe_not_supported: "Votre navigateur ne supporte pas les iframes."
click_to_open_pdf: "Cliquez ici pour ouvrir le PDF"