Correction de bugs, ajout champs urgents dans la modal d'initialisation du ticket et ajout d'un configuration pour l'affichage des tabs dans la homepage

This commit is contained in:
Boris Waaub
2025-09-22 09:23:30 +00:00
committed by Julien Fastré
parent ec9d0be70b
commit e1ef65d4ca
19 changed files with 242 additions and 141 deletions

View File

@@ -94,7 +94,7 @@ class StoredObject implements Document, TrackCreationInterface
/**
* @var Collection<int, StoredObjectVersion>&Selectable<int, StoredObjectVersion>
*/
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true)]
#[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true, fetch: 'EAGER')]
private Collection&Selectable $versions;
/**

View File

@@ -2,5 +2,11 @@ import App from "ChillMainAssets/vuejs/HomepageWidget/App.vue";
import { createApp } from "vue";
import { store } from "ChillMainAssets/vuejs/HomepageWidget/store";
declare global {
interface Window {
homepage_config: string;
}
}
const _app = createApp(App);
_app.use(store).mount("#homepage_widget");

View File

@@ -278,3 +278,25 @@ export interface addNewEntities {
selected: Selected[];
modal: Modal;
}
export enum HomepageTabs {
MyCustoms,
MyTickets,
MyNotifications,
MyAccompanyingCourses,
MyEvaluations,
MyTasks,
MyWorkflows,
}
export interface HomepageConfig {
defaultTab: HomepageTabs;
displayTabs: HomepageTabs[];
}
export interface TabDefinition {
key: HomepageTabs;
label: string;
icon: string | null;
counter: () => number;
}

View File

@@ -1,83 +1,15 @@
<template>
<h2>{{ trans(MAIN_TITLE) }}</h2>
<ul class="nav nav-tabs">
<li class="nav-item">
<li v-for="tab in displayedTabs" :key="tab.key" class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyCustoms' }"
@click="selectTab('MyCustoms')"
:class="{ active: activeTab === tab.key }"
@click="selectTab(tab.key)"
>
<i class="fa fa-dashboard" />
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyTickets' }"
@click="selectTab('MyTickets')"
>
{{ trans(MY_TICKETS_TAB) }}
<tab-counter :count="ticketListState.value?.count || 0" />
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyNotifications' }"
@click="selectTab('MyNotifications')"
>
{{ trans(MY_NOTIFICATIONS_TAB) }}
<tab-counter :count="state.value?.notifications?.count || 0" />
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyAccompanyingCourses' }"
@click="selectTab('MyAccompanyingCourses')"
>
{{ trans(MY_ACCOMPANYING_COURSES_TAB) }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyEvaluations' }"
@click="selectTab('MyEvaluations')"
>
{{ trans(MY_EVALUATIONS_TAB) }}
<tab-counter :count="state.value?.evaluations?.count || 0" />
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyTasks' }"
@click="selectTab('MyTasks')"
>
{{ trans(MY_TASKS_TAB) }}
<tab-counter
:count="
(state.value?.tasks?.warning?.count || 0) +
(state.value?.tasks?.alert?.count || 0)
"
/>
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: activeTab === 'MyWorkflows' }"
@click="selectTab('MyWorkflows')"
>
{{ trans(MY_WORKFLOWS_TAB) }}
<tab-counter
:count="
(state.value?.workflows?.count || 0) +
(state.value?.workflowsCc?.count || 0)
"
/>
<i v-if="tab.icon" :class="tab.icon" />
<span v-else>{{ tab.label }}</span>
<tab-counter v-if="tab.counter" :count="tab.counter()" />
</a>
</li>
<li class="nav-item loading ms-auto py-2" v-if="loading">
@@ -87,26 +19,25 @@
/>
</li>
</ul>
<div class="my-4">
<my-tickets v-if="activeTab == 'MyTickets'" />
<my-customs v-if="activeTab === 'MyCustoms'" />
<my-works v-else-if="activeTab === 'MyWorks'" />
<my-evaluations v-else-if="activeTab === 'MyEvaluations'" />
<my-tasks v-else-if="activeTab === 'MyTasks'" />
<my-accompanying-courses
v-else-if="activeTab === 'MyAccompanyingCourses'"
<MyCustoms v-if="activeTab === HomepageTabs.MyCustoms" />
<MyTickets v-else-if="activeTab === HomepageTabs.MyTickets" />
<MyNotifications v-else-if="activeTab === HomepageTabs.MyNotifications" />
<MyAccompanyingCourses
v-else-if="activeTab === HomepageTabs.MyAccompanyingCourses"
/>
<my-notifications v-else-if="activeTab === 'MyNotifications'" />
<my-workflows v-else-if="activeTab === 'MyWorkflows'" />
<MyEvaluations v-else-if="activeTab === HomepageTabs.MyEvaluations" />
<MyTasks v-else-if="activeTab === HomepageTabs.MyTasks" />
<MyWorkflows v-else-if="activeTab === HomepageTabs.MyWorkflows" />
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { useStore } from "vuex";
// Components
import MyCustoms from "./MyCustoms.vue";
import MyWorks from "./MyWorks.vue";
import MyEvaluations from "./MyEvaluations.vue";
import MyTasks from "./MyTasks.vue";
import MyAccompanyingCourses from "./MyAccompanyingCourses.vue";
@@ -114,6 +45,8 @@ import MyNotifications from "./MyNotifications.vue";
import MyWorkflows from "./MyWorkflows.vue";
import MyTickets from "./MyTickets.vue";
import TabCounter from "./TabCounter.vue";
// Translations
import {
MAIN_TITLE,
MY_TICKETS_TAB,
@@ -125,33 +58,97 @@ import {
LOADING,
trans,
} from "translator";
// Types
import {
HomepageConfig,
HomepageTabs,
TabDefinition,
} from "ChillMainAssets/types";
const store = useStore();
const activeTab = ref("MyCustoms");
const loading = computed(() => store.state.loading);
defineExpose({ HomepageTabs });
const homepageConfig = ref<HomepageConfig>(JSON.parse(window.homepage_config));
const state = computed(() => store.state.homepage);
const ticketListState = computed(() => store.state.ticketList);
function selectTab(tab: string) {
if (tab !== "MyCustoms") {
const tabDefinitions: TabDefinition[] = [
{
key: HomepageTabs.MyCustoms,
label: "",
icon: "fa fa-dashboard",
counter: () => 0,
},
{
key: HomepageTabs.MyTickets,
label: trans(MY_TICKETS_TAB),
icon: null,
counter: () => ticketListState.value?.count,
},
{
key: HomepageTabs.MyNotifications,
label: trans(MY_NOTIFICATIONS_TAB),
icon: null,
counter: () => state.value?.notifications?.count,
},
{
key: HomepageTabs.MyAccompanyingCourses,
label: trans(MY_ACCOMPANYING_COURSES_TAB),
icon: null,
counter: () => state.value?.accompanyingCourses?.count,
},
{
key: HomepageTabs.MyEvaluations,
label: trans(MY_EVALUATIONS_TAB),
icon: null,
counter: () => state.value?.evaluations?.count,
},
{
key: HomepageTabs.MyTasks,
label: trans(MY_TASKS_TAB),
icon: null,
counter: () =>
state.value?.tasks?.warning?.count + state.value?.tasks?.alert?.count,
},
{
key: HomepageTabs.MyWorkflows,
label: trans(MY_WORKFLOWS_TAB),
icon: null,
counter: () =>
state.value?.workflows?.count + state.value?.workflowsCc?.count,
},
];
const displayedTabs = computed(() => {
// Always show MyCustoms first if present
const tabs = [] as TabDefinition[];
for (const tabEnum of homepageConfig.value.displayTabs) {
const def = tabDefinitions.find(
(t) => t.key === Number(HomepageTabs[tabEnum]),
);
if (def) tabs.push(def);
}
return tabs.filter(Boolean);
});
const activeTab = ref(Number(HomepageTabs[homepageConfig.value.defaultTab]));
const loading = computed(() => store.state.loading);
function selectTab(tab: HomepageTabs) {
if (tab !== HomepageTabs.MyCustoms) {
store.dispatch("getByTab", { tab: tab });
}
activeTab.value = tab;
}
onMounted(() => {
for (const m of [
"MyTickets",
"MyNotifications",
"MyAccompanyingCourses",
// 'MyWorks',
"MyEvaluations",
"MyTasks",
"MyWorkflows",
]) {
store.dispatch("getByTab", { tab: m, param: "countOnly=1" });
for (const tab of displayedTabs.value) {
if (tab.key !== HomepageTabs.MyCustoms) {
store.dispatch("getByTab", { tab: tab.key, param: "countOnly=1" });
}
}
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<span v-if="isCounterAvailable" class="badge rounded-pill bg-danger">
<span v-if="isCounterAvailable" class="badge rounded-pill bg-danger mx-2">
{{ count }}
</span>
</template>

View File

@@ -13,6 +13,7 @@ import {
WorflowCc,
} from "ChillPersonAssets/types";
import { RootState } from "..";
import { HomepageTabs } from "ChillMainAssets/types";
export interface TasksState {
warning: PaginationResponse<Warning>;
@@ -82,17 +83,6 @@ export const moduleHomepage: Module<State, RootState> = {
isWorkflowsLoaded(state) {
return Array.isArray(state.workflows.results);
},
counter(state, getters, rootState, rootGetters) {
return {
tickets: rootGetters["ticketList/getCount"] || 0,
evaluations: state.evaluations.count || 0,
tasksWarning: state.tasks.warning.count || 0,
tasksAlert: state.tasks.alert.count || 0,
accompanyingCourses: state.accompanyingCourses.count || 0,
notifications: state.notifications.count || 0,
workflows: state.workflows.count || 0,
};
},
},
mutations: {
addEvaluations(state, evaluations) {
@@ -129,20 +119,20 @@ export const moduleHomepage: Module<State, RootState> = {
actions: {
async getByTab({ commit, getters, dispatch }, { tab, param }) {
switch (tab) {
case "MyTickets":
case HomepageTabs.MyTickets:
if (!getters.isTicketsLoaded) {
commit("setTicketsLoading", true);
// Utilise l'action du module ticket_list
await dispatch(
"fetchTicketList",
{ byAddresseeToMe: true },
{ byAddresseeToMe: true, byCurrentState: "open" },
{ root: true },
);
commit("setTicketsLoading", false);
}
break;
case "MyEvaluations":
case HomepageTabs.MyEvaluations:
if (!getters.isEvaluationsLoaded) {
commit("setLoading", true);
const url = `/api/1.0/person/accompanying-period/work/evaluation/my-near-end${"?" + param}`;
@@ -157,7 +147,7 @@ export const moduleHomepage: Module<State, RootState> = {
});
}
break;
case "MyTasks":
case HomepageTabs.MyTasks:
if (!(getters.isTasksWarningLoaded && getters.isTasksAlertLoaded)) {
commit("setLoading", true);
const urlWarning = `/api/1.0/task/single-task/list/my?f[q]=&f[checkboxes][status][]=warning&f[checkboxes][states][]=new&f[checkboxes][states][]=in_progress${"&" + param}`,
@@ -182,7 +172,7 @@ export const moduleHomepage: Module<State, RootState> = {
});
}
break;
case "MyAccompanyingCourses":
case HomepageTabs.MyAccompanyingCourses:
if (!getters.isAccompanyingCoursesLoaded) {
commit("setLoading", true);
const url = `/api/1.0/person/accompanying-course/list/by-recent-attributions${"?" + param}`;
@@ -197,7 +187,7 @@ export const moduleHomepage: Module<State, RootState> = {
});
}
break;
case "MyNotifications":
case HomepageTabs.MyNotifications:
if (!getters.isNotificationsLoaded) {
commit("setLoading", true);
const url = `/api/1.0/main/notification/my/unread${"?" + param}`;
@@ -212,7 +202,7 @@ export const moduleHomepage: Module<State, RootState> = {
});
}
break;
case "MyWorkflows":
case HomepageTabs.MyWorkflows:
if (!getters.isWorflowsLoaded) {
commit("setLoading", true);
makeFetch("GET", "/api/1.0/main/workflow/my")

View File

@@ -11,5 +11,13 @@
{% endblock %}
{% block js %}
<script type="text/javascript">
window.homepage_config = JSON.stringify({
defaultTab: 'MyTickets',
displayTabs: ['MyCustoms', 'MyTickets', 'MyNotifications']
});
</script>
{{ encore_entry_script_tags('page_homepage_widget') }}
{% endblock %}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\TicketBundle\Controller;
use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\Request;
@@ -22,4 +23,12 @@ final class MotiveApiController extends ApiController
/* @var $query QueryBuilder */
$query->andWhere('e.active = TRUE');
}
protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array
{
return match ($request->getMethod()) {
Request::METHOD_GET => ['groups' => ['read', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY]],
default => parent::getContextForSerialization($action, $request, $_format, $entity),
};
}
}

View File

@@ -44,7 +44,7 @@ class Motive
/**
* @var Collection<int, StoredObject>
*/
#[ORM\ManyToMany(targetEntity: StoredObject::class)]
#[ORM\ManyToMany(targetEntity: StoredObject::class, fetch: 'EAGER')]
#[ORM\JoinTable(name: 'motive_stored_objects', schema: 'chill_ticket')]
private Collection $storedObjects;

View File

@@ -186,6 +186,8 @@ export interface TicketFilterParams {
export interface TicketInitForm {
content: string;
motive?: Motive;
addressees: UserGroupOrUser[];
persons: Person[];
caller: Person | null;
emergency: TicketEmergencyState;
}

View File

@@ -87,12 +87,16 @@ async function handleFormSubmit(ticketForm: TicketInitForm) {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
}
if (ticketForm.content && ticketForm.content.trim() !== "") {
await store.dispatch("createComment", ticketForm.content);
} else {
toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING));
return;
}
await store.dispatch("setEmergency", ticketForm.emergency);
await store.dispatch("setAddressees", ticketForm.addressees);
await store.dispatch("setPersons", ticketForm.persons);
// Forcer le rafraîchissement des composants
@@ -111,13 +115,13 @@ function closeModal() {
showTicketInitFormModal.value = false;
}
onMounted(async () => {
onMounted(() => {
try {
await store.dispatch("getCurrentUser");
await store.dispatch("fetchMotives");
await store.dispatch("fetchUserGroups");
await store.dispatch("fetchUsers");
await store.dispatch("getSuggestedPersons");
store.dispatch("getCurrentUser");
store.dispatch("fetchMotives");
store.dispatch("fetchUserGroups");
store.dispatch("fetchUsers");
store.dispatch("getSuggestedPersons");
showTicketInitFormModal.value = store.getters.isIncompleteTicket;
} catch (error) {
toast.error(error as string);

View File

@@ -41,6 +41,7 @@
<motive-selector-component
v-model="motive"
:motives="motives"
open-direction="top"
v-if="activeTab === 'set_motive'"
/>
<div v-if="activeTab === 'persons_state'">

View File

@@ -46,10 +46,12 @@ const suggestedValues = ref<Entities[]>([...props.suggested]);
watch(
() => [props.suggested, props.modelValue],
() => {
const modelValue = props.modelValue ?? [];
// Mise à jour des entités sélectionnées
selectedEntities.value = [...(props.modelValue as Entities[])];
// Filtrage des suggestions
suggestedValues.value = props.suggested.filter((suggested: Entities) => {
return !modelValue.some((selected: Entities) => {
return !props.modelValue.some((selected: Entities) => {
if (suggested.type == "user_group" && selected.type == "user_group") {
switch (selected.excludeKey) {
case "level":

View File

@@ -9,6 +9,7 @@
}"
@click="toggleEmergency"
:disabled="props.disabled"
type="button"
>
{{ trans(CHILL_TICKET_TICKET_BANNER_EMERGENCY) }}
</button>

View File

@@ -8,7 +8,7 @@
label="label"
:custom-label="customLabel"
track-by="id"
open-direction="top"
:open-direction="openDirection"
:multiple="false"
:searchable="true"
:placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)"
@@ -46,10 +46,20 @@ import {
// Component
import PelotonComponent from "../PelotonComponent.vue";
const props = defineProps<{
modelValue?: Motive;
motives: Motive[];
}>();
const props = defineProps({
modelValue: {
type: Object as () => Motive | undefined,
default: undefined,
},
motives: {
type: Array as () => Motive[],
default: () => [],
},
openDirection: {
type: String,
default: "bottom",
},
});
const emit =
defineEmits<(e: "update:modelValue", value: Motive | undefined) => void>();

View File

@@ -84,7 +84,7 @@ const currentPersons = computed(
() => store.getters.getCurrentPersons as Person[],
);
const previousTickets = computed(
() => store.getters.getTicketList as TicketSimple[],
() => store.getters.getPreviousTicketList as TicketSimple[],
);
onMounted(async () => {

View File

@@ -12,7 +12,16 @@
:motives="motives"
/>
</div>
<!-- Attribution des tickets -->
<div class="mb-3">
<label class="form-label">
{{ trans(CHILL_TICKET_TICKET_ADD_COMMENT_TITLE) }}
</label>
<addressee-selector-component
v-model="ticketForm.addressees"
:suggested="userGroups"
/>
</div>
<!-- Sélection des personnes -->
<div class="row mb-3">
<div class="col-md-6">
@@ -51,6 +60,22 @@
:motive="ticketForm.motive"
/>
</div>
<div class="mb-3">
<label class="form-label pe-2" for="emergency">
{{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }}
</label>
<toggle-component
v-model="isEmergency"
:on-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:off-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)"
:classColor="{
on: 'bg-warning',
off: 'bg-secondary',
}"
id="emergency"
class="float-end"
/>
</div>
<!-- Boutons d'action -->
<div class="d-flex justify-content-end gap-2 mt-4">
@@ -67,16 +92,23 @@
</template>
<script setup lang="ts">
import { reactive, watch } from "vue";
import { computed, reactive, ref, 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";
import AddresseeSelectorComponent from "./Addressee/AddresseeSelectorComponent.vue";
import ToggleComponent from "../../TicketList/components/ToggleComponent.vue";
// Types
import { Motive, Ticket, TicketInitForm } from "../../../types";
import {
Motive,
Ticket,
TicketEmergencyState,
TicketInitForm,
} from "../../../types";
import { Person } from "ChillPersonAssets/types";
// Translations
@@ -90,7 +122,9 @@ import {
CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON,
CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL,
CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL,
CHILL_TICKET_LIST_FILTER_EMERGENCY,
} from "translator";
import { UserGroup, UserGroupOrUser } from "ChillMainAssets/types";
const props = defineProps<{
ticket: Ticket;
@@ -107,11 +141,19 @@ const store = useStore();
const ticketForm = reactive({
content: "",
addressees: props.ticket.currentAddressees as UserGroupOrUser[],
motive: props.ticket.currentMotive as Motive | null,
persons: props.ticket.currentPersons as Person[],
caller: props.ticket.caller as Person | null,
emergency: props.ticket.emergency as TicketEmergencyState,
} as TicketInitForm);
const isEmergency = ref<boolean>(
props.ticket.emergency == "yes" ? true : false,
);
const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]);
watch(
() => ticketForm.caller,
async (newCaller) => {
@@ -124,16 +166,18 @@ function submitForm() {
emit("submit", {
content: ticketForm.content,
motive: ticketForm.motive,
addressees: [...ticketForm.addressees],
persons: [...ticketForm.persons],
caller: ticketForm.caller,
emergency: isEmergency.value ? "yes" : "no",
});
}
function resetForm() {
ticketForm.content = "";
ticketForm.motive = undefined;
ticketForm.persons = [];
ticketForm.caller = null;
ticketForm.emergency = props.ticket.emergency as TicketEmergencyState;
}
</script>

View File

@@ -36,6 +36,11 @@ export const moduleTicketList: Module<State, RootState> = {
getTicketList(state): TicketSimple[] {
return state.ticket_list;
},
getPreviousTicketList(state, getters, rootState): TicketSimple[] {
return state.ticket_list.filter(
(ticket) => ticket.id !== rootState.ticket.ticket.id,
);
},
getPagination(state) {
return state.pagination;
},

View File

@@ -98,9 +98,9 @@ onMounted(async () => {
byAddresseeToMe: false,
};
try {
await store.dispatch("getCurrentUser");
await store.dispatch("fetchTicketList", filters);
await store.dispatch("fetchMotives");
store.dispatch("getCurrentUser");
store.dispatch("fetchMotives");
} finally {
isLoading.value = false;
}