Merge branch 'ticket-app-create-template' into 'ticket-app-master'

Mise à jour des messages de l'interface utilisateur pour inclure les...

See merge request Chill-Projet/chill-bundles!689
This commit is contained in:
Julien Fastré 2024-05-13 13:34:43 +00:00
commit 76c076a5f3
35 changed files with 1239 additions and 116 deletions

View File

@ -0,0 +1,6 @@
kind: Fixed
body: Fix broken link in homepage when a evaluation from a closed acc period was present
in the homepage widget
time: 2024-04-16T16:18:17.888645172+02:00
custom:
Issue: "270"

3
.changes/v2.18.2.md Normal file
View File

@ -0,0 +1,3 @@
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record

View File

@ -6,6 +6,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.18.2 - 2024-04-12
### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26
### Fixed
* Fix layout issue in document generation for admin (minor)

View File

@ -8,6 +8,16 @@ Chill can store a list of geolocated address references, which are used to sugge
Those addresses may be load from a dedicated source.
Countries
=========
In order to load addresses into the chill application we first have to make sure that a list of countries is present.
To import the countries run the following command.
.. code-block:: bash
bin/console chill:main:countries:populate
In France
=========

View File

@ -1,6 +1,7 @@
<template>
<span class="chill-entity entity-user">
{{ user.label }}
{{ user }}
<span class="user-job" v-if="user.user_job !== null">({{ user.user_job.label.fr }})</span> <span class="main-scope" v-if="user.main_scope !== null">({{ user.main_scope.name.fr }})</span> <span v-if="user.isAbsent" class="badge bg-danger rounded-pill" :title="Absent">A</span>
</span>
</template>

View File

@ -23,7 +23,7 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
*/
class PostalCodeFRFromOpenData
{
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/data-files/019HexaSmal.csv';
private const CSV = 'https://datanova.laposte.fr/data-fair/api/v1/datasets/laposte-hexasmal/metadata-attachments/base-officielle-codes-postaux.csv';
public function __construct(private readonly PostalCodeBaseImporter $baseImporter, private readonly HttpClientInterface $client, private readonly LoggerInterface $logger) {}
@ -48,7 +48,7 @@ class PostalCodeFRFromOpenData
fseek($tmpfile, 0);
$csv = Reader::createFromStream($tmpfile);
$csv->setDelimiter(';');
$csv->setDelimiter(',');
$csv->setHeaderOffset(0);
foreach ($csv as $offset => $record) {
@ -63,23 +63,23 @@ class PostalCodeFRFromOpenData
private function handleRecord(array $record): void
{
if ('' !== trim($record['coordonnees_geographiques'] ?? $record['coordonnees_gps'])) {
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', $record['coordonnees_geographiques'] ?? $record['coordonnees_gps']));
if ('' !== trim((string) $record['_geopoint'])) {
[$lat, $lon] = array_map(static fn ($el) => (float) trim($el), explode(',', (string) $record['_geopoint']));
} else {
$lat = $lon = 0.0;
}
$ref = trim((string) $record['Code_commune_INSEE']);
$ref = trim((string) $record['code_commune_insee']);
if (str_starts_with($ref, '987')) {
// some differences in French Polynesia
$ref .= '.'.trim((string) $record['Libellé_d_acheminement']);
$ref .= '.'.trim((string) $record['libelle_d_acheminement']);
}
$this->baseImporter->importCode(
'FR',
trim((string) $record['Libellé_d_acheminement']),
trim((string) $record['Code_postal']),
trim((string) $record['libelle_d_acheminement']),
trim((string) $record['code_postal']),
$ref,
'INSEE',
$lat,

View File

@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\PersonBundle\Repository\AccompanyingPeriod;
use Chill\MainBundle\Entity\User;
use Chill\PersonBundle\Entity\AccompanyingPeriod;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork;
use Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation;
use Doctrine\ORM\EntityManagerInterface;
@ -88,6 +89,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
->where(
$qb->expr()->andX(
$qb->expr()->isNull('e.endDate'),
$qb->expr()->neq('period.step', ':closed'),
$qb->expr()->gte(':now', $qb->expr()->diff('e.maxDate', 'e.warningInterval')),
$qb->expr()->orX(
$qb->expr()->eq('period.user', ':user'),
@ -100,6 +102,7 @@ class AccompanyingPeriodWorkEvaluationRepository implements ObjectRepository
->setParameters([
'user' => $user,
'now' => new \DateTimeImmutable('now'),
'closed' => AccompanyingPeriod::STEP_CLOSED,
]);
return $qb;

View File

@ -3,10 +3,10 @@
<h2><a id="section-10"></a>{{ $t('persons_associated.title')}}</h2>
<div v-if="currentParticipations.length > 0">
<label class="col-form-label">{{ $tc('persons_associated.counter', counter) }}</label>
<label class="col-form-label">{{ $t('persons_associated.counter', { count: counter }) }}</label>
</div>
<div v-else>
<label class="chill-no-data-statement">{{ $tc('persons_associated.counter', counter) }}</label>
<label class="chill-no-data-statement">{{ $t('persons_associated.counter', { count: counter }) }}</label>
</div>
<div v-if="participationWithoutHousehold.length > 0" class="alert alert-warning no-household">

View File

@ -4,10 +4,10 @@
<h2><a id="section-90"></a>{{ $t('resources.title')}}</h2>
<div v-if="resources.length > 0">
<label class="col-form-label">{{ $tc('resources.counter', counter) }}</label>
<label class="col-form-label">{{ $t('resources.counter', { count: counter }) }}</label>
</div>
<div v-else>
<label class="chill-no-data-statement">{{ $tc('resources.counter', counter) }}</label>
<label class="chill-no-data-statement">{{ $t('resources.counter', { count: counter }) }}</label>
</div>
<div class="flex-table mb-3">

View File

@ -23,7 +23,7 @@
data-bs-toggle="collapse"
aria-expanded="false"
@click="toggleHouseholdSuggestion">
{{ $tc('household_members_editor.show_household_suggestion', countHouseholdSuggestion) }}
{{ $t('household_members_editor.show_household_suggestion', { count: countHouseholdSuggestion }) }}
</button>
<button v-if="showHouseholdSuggestion"
class="accordion-button"

View File

@ -17,7 +17,7 @@
<div class="search">
<label class="col-form-label" style="float: right;">
{{ $tc('add_persons.suggested_counter', suggestedCounter) }}
{{ $t('add_persons.suggested_counter', { count: suggestedCounter }) }}
</label>
<input id="search-persons"
@ -42,7 +42,7 @@
</a>
</span>
<span v-if="selectedCounter > 0">
{{ $tc('add_persons.selected_counter', selectedCounter) }}
{{ $t('add_persons.selected_counter', { count: selectedCounter }) }}
</span>
</div>
</div>

View File

@ -52,9 +52,7 @@
{{ $t('renderbox.deathdate') + ' ' + deathdate }}
</time>
<span v-if="options.addAge && person.birthdate" class="age">{{
$tc('renderbox.years_old', person.age)
}}</span>
<span v-if="options.addAge && person.birthdate" class="age">{{ $t('renderbox.years_old', { n: person.age }) }}</span>
</p>
</div>
</div>

View File

@ -7,7 +7,7 @@
<span :class="'altname altname-' + altNameKey"> ({{ altNameLabel }})</span>
</span>
<span v-if="person.suffixText" class="suffixtext">&nbsp;{{ person.suffixText }}</span>
<span class="age" v-if="this.addAge && person.birthdate !== null && person.deathdate === null">{{ $tc('renderbox.years_old', person.age) }}</span>
<span class="age" v-if="this.addAge && person.birthdate !== null && person.deathdate === null">{{ $t('renderbox.years_old', { n: person.age }) }}</span>
<span v-else-if="this.addAge && person.deathdate !== null">&nbsp;()</span>
</span>
</template>

View File

@ -69,6 +69,13 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
#[ORM\OneToMany(targetEntity: PersonHistory::class, mappedBy: 'ticket')]
private Collection $personHistories;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: true)]
private ?User $updatedBy = null;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->addresseeHistory = new ArrayCollection();
@ -76,6 +83,7 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
$this->motiveHistories = new ArrayCollection();
$this->personHistories = new ArrayCollection();
$this->inputHistories = new ArrayCollection();
}
public function getId(): ?int
@ -211,4 +219,16 @@ class Ticket implements TrackCreationInterface, TrackUpdateInterface
{
return $this->addresseeHistory;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedBy(): ?User
{
return $this->updatedBy;
}
}

View File

@ -2,10 +2,9 @@ import {
DateTime,
TranslatableString,
User,
UserGroup,
UserGroupOrUser
} from "../../../../ChillMainBundle/Resources/public/types";
import {Person} from "../../../../ChillPersonBundle/Resources/public/types";
import { Person } from "../../../../ChillPersonBundle/Resources/public/types";
export interface Motive {
type: "ticket_motive"
@ -21,7 +20,7 @@ interface TicketHistory<T extends string, D extends object> {
data: D
}
interface PersonHistory {
export interface PersonHistory {
type: "ticket_person_history",
id: number,
startDate: DateTime,
@ -32,7 +31,7 @@ interface PersonHistory {
createdAt: DateTime|null
}
interface MotiveHistory {
export interface MotiveHistory {
type: "ticket_motive_history",
id: number,
startDate: null,
@ -42,7 +41,7 @@ interface MotiveHistory {
createdAt: DateTime|null,
}
interface Comment {
export interface Comment {
type: "ticket_comment",
id: number,
content: string,
@ -52,7 +51,7 @@ interface Comment {
updatedAt: DateTime|null,
}
interface AddresseeHistory {
export interface AddresseeHistory {
type: "ticket_addressee_history",
id: number,
startDate: DateTime|null,
@ -69,8 +68,9 @@ interface AddPersonEvent extends TicketHistory<"add_person", PersonHistory> {};
interface AddCommentEvent extends TicketHistory<"add_comment", Comment> {};
interface SetMotiveEvent extends TicketHistory<"set_motive", MotiveHistory> {};
interface AddAddressee extends TicketHistory<"add_addressee", AddresseeHistory> {};
interface RemoveAddressee extends TicketHistory<"remove_addressee", AddresseeHistory> {};
type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent | AddAddressee;
type TicketHistoryLine = AddPersonEvent | AddCommentEvent | SetMotiveEvent | AddAddressee | RemoveAddressee;
export interface Ticket {
type: "ticket_ticket",
@ -80,5 +80,7 @@ export interface Ticket {
currentPersons: Person[],
currentMotive: null|Motive,
history: TicketHistoryLine[],
createdAt: DateTime|null,
updatedBy: User|null,
}

View File

@ -1,59 +1,161 @@
<template>
<div>
<p>{{ ticket.externalRef }}</p>
<p>{{ ticket.currentMotive }}</p>
</div>
<Teleport to="#header-ticket-main">
<div class="container-xxl text-primary">
<div class="row">
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<h2>#{{ ticket.externalRef }}</h2>
<h1 v-if="ticket.currentMotive">
{{ ticket.currentMotive.label.fr }}
</h1>
<p class="chill-no-data-statement" v-else>
{{ $t("banner.no_motive") }}
</p>
</div>
<div v-for="person in ticket.currentPersons as Person[]" :key="person.id">
<p>{{ person.firstName }}</p>
<p>{{ person.lastName }}</p>
</div>
<div v-for="ticket_history_line in ticket.history">
<p>{{ ticket_history_line.event_type}}</p>
<p>{{ ticket_history_line.data}}</p>
</div>
<div class="col-md-6 col-sm-12">
<div class="float-end">
<h1>
<span class="badge text-bg-chill-green text-white">
{{ $t("banner.open") }}
</span>
</h1>
<h3 class="fst-italic" v-if="ticket.createdAt">
{{
$t("banner.since", {
count: getSince(ticket.createdAt),
})
}}
</h3>
</div>
</div>
</div>
</div>
</Teleport>
<Teleport to="#header-ticket-details">
<div class="container-xxl">
<div class="row justify-content-between">
<!-- <div class="col-md-4 col-sm-12">
<h3 class="text-primary">{{ $t("concerned_patient") }}</h3>
</div> -->
<div class="col-md-6 col-sm-12 ps-md-5 ps-xxl-0">
<h3 class="text-primary">{{ $t("banner.caller") }}</h3>
<h2>
<person-render-box
render="badge"
v-for="person in ticket.currentPersons"
:key="person.id"
:person="person"
:options="{
addLink: true,
addId: false,
addAltNames: false,
addEntity: true,
addInfo: true,
hLevel: 3,
isMultiline: true,
isConfidential: false,
}"
/>
</h2>
</div>
<div class="col-md-6 col-sm-12">
<h3 class="text-primary">{{ $t("banner.speaker") }}</h3>
<h2>
<span
class="badge text-bg-light m-1"
v-for="user_group in ticket.currentAddressees.filter((addressee) => addressee.type == 'user') as Array<User>"
:key="user_group.id"
>
{{ user_group.label }}
</span>
<span
class="badge text-bg-light m-1"
v-for="user_group in ticket.currentAddressees.filter((addressee) => addressee.type == 'user_group') as Array<UserGroup>"
:key="user_group.id"
>
{{ user_group.label.fr }}
</span>
</h2>
</div>
</div>
</div>
</Teleport>
<div class="container-xxl pt-1" style="padding-bottom: 55px">
<ticket-selector-component :tickets="[]" />
<ticket-history-list-component :history="ticketHistory" />
</div>
<action-toolbar-component />
</template>
<script lang="ts">
import { computed, defineComponent, inject, onMounted, ref } from "vue";
import { useStore } from "vuex";
import { computed, defineComponent, inject, onMounted } from 'vue';
import { useStore } from 'vuex';
// Types
import {
DateTime,
User,
UserGroup,
} from "../../../../../../ChillMainBundle/Resources/public/types";
import { Motive, Ticket } from "../../types";
// Components
import TicketSelectorComponent from "./components/TicketSelectorComponent.vue";
import TicketHistoryListComponent from "./components/TicketHistoryListComponent.vue";
import ActionToolbarComponent from "./components/ActionToolbarComponent.vue";
import PersonRenderBox from "../../../../../../ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue";
// Types
import { Person } from '../../../../../../ChillPersonBundle/Resources/public/types';
import { Motive, Ticket } from '../../types';
export default defineComponent({
name: "App",
components: {
TicketSelectorComponent,
TicketHistoryListComponent,
ActionToolbarComponent,
PersonRenderBox,
},
setup() {
const store = useStore();
const toast = inject("toast") as any;
const headline = ref({} as HTMLHeadingElement);
store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket);
export default defineComponent({
name: 'App',
const motives = computed(() => store.getters.getMotives as Motive[]);
const ticket = computed(() => store.getters.getTicket as Ticket);
const ticketHistory = computed(
() => store.getters.getDistinctAddressesHistory
);
setup() {
const store = useStore();
const toast = inject('toast') as any;
function getSince(createdAt: any) {
const today = new Date();
const date = new Date(createdAt.date);
store.commit('setTicket', JSON.parse(window.initialTicket) as Ticket);
const timeDiff = Math.abs(today.getTime() - date.getTime());
const daysDiff = Math.ceil(timeDiff / (1000 * 3600 * 24));
return daysDiff;
}
const motives = computed(() => store.getters.getMotives as Motive[])
const ticket = computed(() => store.getters.getTicket as Ticket)
onMounted(async () => {
try {
await store.dispatch("fetchMotives");
await store.dispatch("fetchUserGroups");
await store.dispatch("fetchUsers");
} catch (error) {
toast.error(error);
}
});
onMounted(() => {
try {
store.dispatch('fetchMotives')
} catch (error) {
toast.error(error)
};
});
return {
motives,
ticket,
};
},
});
return {
ticketHistory,
headline,
motives,
ticket,
getSince,
};
},
});
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,191 @@
<template>
<div class="fixed-bottom">
<div class="footer-ticket-details">
<div class="tab-content p-2">
<div v-if="activeTab">
<label class="col-form-label">
{{ $t(`${activeTab}.title`) }}
</label>
</div>
<div v-if="activeTab === 'comment'">
<form @submit.prevent="createComment">
<ckeditor
name="content"
:placeholder="$t('comment.content')"
:editor="editor"
v-model="content"
tag-name="textarea">
</ckeditor>
<div class="d-flex justify-content-end p-2">
<button class="btn btn-chill-green text-white float-right" type="submit">
<i class="fa fa-pencil"></i>
{{ $t("comment.save") }}
</button>
</div>
</form>
</div>
<div v-if="activeTab === 'transfert'">
<form @submit.prevent="setAdressees" v-if="userGroups.length && users.length">
<addressee-selector-component v-model="addressees" :user-groups="userGroups" :users="users" />
<div class="d-flex justify-content-end p-1">
<button class="btn btn-chill-green text-white float-right" type="submit">
<i class="fa fa-pencil"></i>
{{ $t("transfert.save") }}
</button>
</div>
</form>
</div>
<div v-if="activeTab === 'motive'">
<form @submit.prevent="createMotive">
<motive-selector-component v-model="motive" :motives="motives" />
<div class="d-flex justify-content-end p-1">
<button class="btn btn-chill-green text-white float-right" type="submit">
<i class="fa fa-pencil"></i>
{{ $t("motive.save") }}
</button>
</div>
</form>
</div>
</div>
</div>
<div class="footer-ticket-main">
<ul class="nav nav-tabs justify-content-end">
<li class="nav-item">
<button type="button" class="m-2 btn btn-light" @click="activeTab = 'comment'">
<i class="fa fa-plus"></i>
{{ $t('comment.title') }}
</button>
</li>
<li class="nav-item">
<button type="button" class="m-2 btn btn-light" @click="activeTab = 'transfert'">
<i class="fa fa-paper-plane"></i>
{{ $t('transfert.title') }}
</button>
</li>
<li class="nav-item">
<button type="button" class="m-2 btn btn-light" @click="activeTab = 'motive'">
<i class="fa fa-paint-brush"></i>
{{ $t('motive.title') }}
</button>
</li>
<li class="nav-item">
<button type="button" class="m-2 btn btn-light" @click="activeTab = ''">
<i class="fa fa-bolt"></i>
Fermer
</button>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, inject, ref } from "vue";
import { useI18n } from "vue-i18n";
import { useStore } from "vuex";
import CKEditor from '@ckeditor/ckeditor5-vue';
import ClassicEditor from "../../../../../../../ChillMainBundle/Resources/public/module/ckeditor5";
// Types
import { User, UserGroup, UserGroupOrUser } from "../../../../../../../ChillMainBundle/Resources/public/types";
import { Comment, Motive, Ticket } from "../../../types";
// Component
import MotiveSelectorComponent from "./MotiveSelectorComponent.vue";
import AddresseeSelectorComponent from "./AddresseeSelectorComponent.vue";
export default defineComponent({
name: "ActionToolbarComponent",
components: {
MotiveSelectorComponent,
AddresseeSelectorComponent,
ckeditor: CKEditor.component,
},
setup() {
const store = useStore();
const { t } = useI18n();
const toast = inject('toast') as any;
const activeTab = ref("");
const ticket = computed(() => store.getters.getTicket as Ticket);
const motives = computed(() => store.getters.getMotives as Motive[]);
const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]);
const users = computed(() => store.getters.getUsers as User[]);
const motive = ref(ticket.value.currentMotive ? ticket.value.currentMotive: {} as Motive);
const content = ref('' as Comment["content"]);
const addressees = ref([] as Array<UserGroupOrUser>);
async function createMotive() {
try {
await store.dispatch("createMotive", {
ticketId: ticket.value.id,
motive: motive.value,
});
toast.success(t("motive.success"))
} catch (error) {
toast.error(error)
}
}
async function createComment() {
try {
await store.dispatch("createComment", {
ticketId: ticket.value.id,
content: content.value,
});
content.value = "";
toast.success(t("comment.success"))
} catch (error) {
toast.error(error)
}
}
async function setAdressees() {
try {
await store.dispatch("setAdressees", {
ticketId: ticket.value.id,
addressees: addressees.value,
});
toast.success(t("transfert.success"))
} catch (error) {
toast.error(error)
}
}
return {
activeTab,
ticket,
motives,
motive,
userGroups,
addressees,
users,
content,
editor: ClassicEditor,
createMotive,
createComment,
setAdressees,
};
},
});
</script>
<style lang="scss" scoped>
div.fixed-bottom {
div.footer-ticket-main {
background: none repeat scroll 0 0 #cabb9f;
}
div.footer-ticket-details {
background: none repeat scroll 0 0 #efe2ca;
}
}
</style>

View File

@ -0,0 +1,215 @@
<template>
<div class="row">
<div class="col-12 col-lg-6 col-md-6 mb-2 text-center">
<span class="m-1">
<input
type="radio"
class="btn-check"
name="options-outlined"
id="level-none"
autocomplete="off"
:value="{}"
v-model="userGroupLevel"
/>
<label :class="`btn btn-outline-primary`" for="level-none">
Aucun
</label>
</span>
<span
v-for="userGroupItem in userGroups.filter(
(userGroup) => userGroup.excludeKey == 'level'
)"
:key="userGroupItem.id"
class="m-1"
>
<input
type="radio"
class="btn-check"
name="options-outlined"
:id="`level-${userGroupItem.id}`"
autocomplete="off"
:value="userGroupItem"
v-model="userGroupLevel"
/>
<label
:class="`btn btn-${userGroupItem.id}`"
:for="`level-${userGroupItem.id}`"
:style="getUserGroupBtnColor(userGroupItem)"
>
{{ userGroupItem.label.fr }}
</label>
</span>
</div>
<div class="col-12 col-lg-6 col-md-6 mb-2 text-center">
<span
v-for="userGroupItem in userGroups.filter(
(userGroup) => userGroup.excludeKey == ''
)"
:key="userGroupItem.id"
class="m-1"
>
<input
type="checkbox"
class="btn-check"
name="options-outlined"
:id="`user-group-${userGroupItem.id}`"
autocomplete="off"
:value="userGroupItem"
v-model="userGroup"
/>
<label
:class="`btn btn-${userGroupItem.id}`"
:for="`user-group-${userGroupItem.id}`"
:style="getUserGroupBtnColor(userGroupItem)"
>
{{ userGroupItem.label.fr }}
</label>
</span>
</div>
<div class="col-12 col-lg-6 col-md-6 mb-2 text-center">
<add-persons
:options="addPersonsOptions"
key="add-person-ticket"
buttonTitle="transfert.user_label"
modalTitle="transfert.user_label"
ref="addPersons"
@addNewPersons="addNewEntity"
/>
</div>
<div class="col-12 col-lg-6 col-md-6 mb-2 mb-2 text-center">
<span class="badge text-bg-light m-1" v-for="user in users">
{{ user.username }}
</span>
</div>
</div>
</template>
<script lang="ts">
import { PropType, computed, defineComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
// Types
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../ChillMainBundle/Resources/public/types";
// Components
import AddPersons from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue";
export default defineComponent({
name: "AddresseeSelectorComponent",
props: {
modelValue: {
type: Array as PropType<UserGroupOrUser[]>,
default: [],
required: false,
},
userGroups: {
type: Array as PropType<UserGroup[]>,
required: true,
},
users: {
type: Array as PropType<User[]>,
required: true,
},
},
components: {
AddPersons,
},
emits: ["update:modelValue"],
setup(props, ctx) {
// Cant use UserGroupOrUser[] because of TS2367
// TS2367: This comparison appears to be unintentional because the types '"user" | "chill_main_user_group"' and '"user_group"' have no overlap.
const addressees = ref(props.modelValue as any[]);
const userGroupLevel = ref({} as UserGroupOrUser);
const userGroup = ref([] as UserGroupOrUser[]);
const users = ref([] as User[]);
const addPersons = ref();
const { t } = useI18n();
function getUserGroupBtnColor(userGroup: UserGroup) {
return [
`.btn-check:checked + .btn-${userGroup.id} {
color: ${userGroup.foregroundColor};
background-color: ${userGroup.backgroundColor};
}`,
];
}
function addNewEntity(datas: any) {
const { selected, modal } = datas;
users.value = selected.map((selected: any) => selected.result);
addressees.value = addressees.value.filter(
(addressee) => addressee.type === "user_group"
);
addressees.value = [...addressees.value, ...users.value];
ctx.emit("update:modelValue", addressees.value);
addPersons.value.resetSearch();
modal.showModal = false;
}
const addPersonsOptions = computed(() => {
return {
uniq: false,
type: ["user"],
priority: null,
button: {
size: "btn-sm",
class: "btn-submit",
},
};
});
watch(userGroupLevel, (userGroupLevelAdd, userGroupLevelRem) => {
if (userGroupLevelRem) {
addressees.value.splice(
addressees.value.indexOf(userGroupLevelRem),
1
);
}
addressees.value.push(userGroupLevelAdd);
ctx.emit("update:modelValue", addressees.value);
});
watch(userGroup, (userGroupAdd) => {
addressees.value = addressees.value.filter(
(addressee) => addressee.excludeKey !== ""
);
addressees.value = [...addressees.value, ...userGroupAdd];
ctx.emit("update:modelValue", addressees.value);
});
return {
addressees,
userGroupLevel,
userGroup,
users,
addPersons,
addPersonsOptions,
addNewEntity,
getUserGroupBtnColor,
customUserGroupLabel(selectedUserGroup: UserGroup) {
return selectedUserGroup.label
? selectedUserGroup.label.fr
: t("transfert.user_group_label");
},
};
},
});
</script>
<style lang="scss" scoped>
.btn-check:checked + .btn,
:not(.btn-check) + .btn:active,
.btn:first-child:active,
.btn.active,
.btn.show {
color: white;
box-shadow: 0 0 0 0.2rem var(--bs-chill-green);
outline: 0;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<div class="row">
<div class="col-12 col-lg-4 col-md-6">
<vue-multiselect name="selectMotive" id="selectMotive" label="label" :custom-label="customLabel"
track-by="id" open-direction="top" :multiple="false" :searchable="true"
:placeholder="$t('motive.label')" :select-label="$t('multiselect.select_label')"
:deselect-label="$t('multiselect.deselect_label')" :selected-label="$t('multiselect.selected_label')"
:options="motives" v-model="motive" />
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import VueMultiselect from "vue-multiselect";
// Types
import { Motive } from "../../../types";
export default defineComponent({
name: "MotiveSelectorComponent",
props: {
modelValue: {
type: Object as PropType<Motive>,
required: false,
},
motives: {
type: Object as PropType<Motive[]>,
required: true,
},
},
components: {
VueMultiselect,
},
emits: ["update:modelValue"],
setup(props, ctx) {
const motive = ref(props.modelValue);
const { t } = useI18n();
watch(motive, (motive) => {
ctx.emit("update:modelValue", motive);
});
return {
motive,
customLabel(motive: Motive) {
return motive.label ? motive.label.fr : t('motive.label');
},
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,49 @@
<template>
<div class="col-12" >
<i class="fa fa-paper-plane" v-if="event_type === 'add_addressee'"></i>
<i class="fa fa-paper-plane-o" v-else></i>
<span class="mx-1" v-if="addressee.type == 'user_group'">
{{
$t(`history.${event_type}_user_group`, {
user_group: addressee.label.fr,
})
}}
</span>
<span class="mx-1" v-else-if="addressee.type == 'user'">
{{
$t(`history.${event_type}_user`, {
user: addressee.username,
})
}}
</span>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref } from "vue";
// Types
import { AddresseeHistory } from "../../../types";
import { UserGroupOrUser } from "../../../../../../../ChillMainBundle/Resources/public/types";
export default defineComponent({
name: "TicketHistoryAddresseeComponent",
props: {
addresseeHistory: {
type: Object as PropType<AddresseeHistory>,
required: true,
},
event_type: {
type: String,
required: true,
},
},
setup(props, ctx) {
return {
addressee: ref(props.addresseeHistory.addressee as UserGroupOrUser),
};
},
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,67 @@
<template>
<div class="col-12">
<i class="fa fa-comment"></i>
<span class="mx-1">
{{ $t("history.comment") }}
</span>
<div class="mt-2">
<div v-html="convertMarkdownToHtml(commentHistory.content)"></div>
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
// Types
import { Comment } from "../../../types";
export default defineComponent({
name: "TicketHistoryCommentComponent",
props: {
commentHistory: {
type: Object as PropType<Comment>,
required: true,
},
},
setup() {
const preprocess = (markdown: string): string => {
return markdown;
};
const postprocess = (html: string): string => {
DOMPurify.addHook("afterSanitizeAttributes", (node: any) => {
if ("target" in node) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
if (
!node.hasAttribute("target") &&
(node.hasAttribute("xlink:href") ||
node.hasAttribute("href"))
) {
node.setAttribute("xlink:show", "new");
}
});
return DOMPurify.sanitize(html);
};
const convertMarkdownToHtml = (markdown: string): string => {
marked.use({ hooks: { postprocess, preprocess } });
const rawHtml = marked(markdown) as string;
return rawHtml;
};
return {
convertMarkdownToHtml,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,91 @@
<template>
<div
class="card my-2 bg-light"
v-for="history_line in history"
:key="history.indexOf(history_line)"
>
<template v-if="!Array.isArray(history_line)">
<div class="card-header">
<span class="fw-bold fst-italic">
{{ formatDate(history_line.at) }}
</span>
<span class="badge bg-white text-black mx-1">{{
history_line.by.username
}}</span>
</div>
<div class="card-body row fst-italic">
<ticket-history-person-component
:personHistory="history_line.data"
v-if="history_line.event_type == 'add_person'"
/>
<ticket-history-motive-component
:motiveHistory="history_line.data"
v-else-if="history_line.event_type == 'set_motive'"
/>
<ticket-history-comment-component
:commentHistory="history_line.data"
v-else-if="history_line.event_type == 'add_comment'"
/>
</div>
</template>
<template v-else>
<div class="card-header">
<span class="fw-bold fst-italic">
{{ formatDate(history_line[0].at) }}
</span>
<span class="badge bg-white text-black mx-1">{{
history_line[0].by.username
}}</span>
</div>
<div
class="card-body row fst-italic"
v-for="addressee in history_line"
:key="history_line.indexOf(addressee)"
>
<ticket-history-addressee-component :addresseeHistory="addressee.data" :event_type="addressee.event_type"/>
</div>
</template>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
import { DateTime } from "../../../../../../../ChillMainBundle/Resources/public/types";
// Types
import { Ticket } from "../../../types";
// Components
import TicketHistoryPersonComponent from "./TicketHistoryPersonComponent.vue";
import TicketHistoryMotiveComponent from "./TicketHistoryMotiveComponent.vue";
import TicketHistoryCommentComponent from "./TicketHistoryCommentComponent.vue";
import TicketHistoryAddresseeComponent from "./TicketHistoryAddresseeComponent.vue";
export default defineComponent({
name: "TicketHistoryListComponent",
components: {
TicketHistoryPersonComponent,
TicketHistoryMotiveComponent,
TicketHistoryCommentComponent,
TicketHistoryAddresseeComponent,
},
props: {
history: {
type: Array as PropType<Ticket["history"]>,
required: true,
},
},
setup() {
function formatDate(d: DateTime) {
const date = new Date(d.datetime);
const month = date.toLocaleString("default", { month: "long" });
return `${date.getDate()} ${month} ${date.getFullYear()}, ${date.toLocaleTimeString()}`;
}
return { formatDate };
},
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,30 @@
<template>
<div class="col-12">
<i class="fa fa-paint-brush"></i>
<span class="mx-1">
{{ $t('history.motive',{ motive: motiveHistory.motive.label.fr }) }}
</span>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue';
// Types
import { MotiveHistory } from '../../../types';
export default defineComponent({
name: 'TicketHistoryMotiveComponent',
props: {
motiveHistory: {
type: Object as PropType<MotiveHistory>,
required: true,
},
},
setup() {}
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,35 @@
<template>
<div class="col-12" v-if="personHistory.createdBy">
<i class="fa fa-eyedropper"></i>
<span class="mx-1">
{{ $t("history.user", { username: personHistory.createdBy.username }) }}
</span>
</div>
<div class="col-12">
<i class="fa fa-bolt" style="min-width: 16px"></i>
<span class="mx-1">
{{ $t("history.person", { person: personHistory.person.text }) }}
</span>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from "vue";
// Type
import { PersonHistory, Ticket } from "../../../types";
export default defineComponent({
name: "TicketHistoryPersonComponent",
props: {
personHistory: {
type: Object as PropType<PersonHistory>,
required: true,
},
},
setup() {},
});
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,41 @@
<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">
{{ $t('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 lang="ts">
import { PropType, defineComponent } from 'vue';
// Types
import { Ticket } from '../../../types';
export default defineComponent({
name: 'TicketSelectorComponent',
props: {
tickets: {
type: Object as PropType<Ticket[]>,
required: true,
},
},
setup() {
function handleClick() {
alert('Sera disponible plus tard')
}
return { handleClick }
}
});
</script>
<style lang="scss" scoped></style>

View File

@ -1,5 +1,52 @@
export const messages = {
import { multiSelectMessages } from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import { personMessages } from "../../../../../../../ChillPersonBundle/Resources/public/vuejs/_js/i18n";
const messages = {
fr: {
hello: "Bonjour {name}"
}
ticket: {
previous_tickets: "Précédents tickets",
},
history: {
person: "Ouverture par appel téléphonique de {person}",
user: "Prise en charge par {username}",
motive: "Motif indiqué: {motive}",
comment: "Commentaire",
add_addressee_user_group: "Groupe {user_group} transferé",
remove_addressee_user_group: "Groupe {user_group} retiré",
add_addressee_user: " Utilisateur {user} Transferé",
remove_addressee_user: "Utilisateur {user} retiré",
},
comment: {
title: "Commentaire",
label: "Ajouter un commentaire",
save: "Enregistrer",
succcess: "Commentaire enregistré",
content: "Ajouter un commentaire",
},
motive: {
title: "Motif",
label: "Choisir un motif",
save: "Enregistrer",
success: "Motif enregistré",
},
transfert: {
title: "Transfert",
user_group_label: "Transferer vers un groupe",
user_label: "Transferer vers un ou plusieurs utilisateurs",
save: "Enregistrer",
success: "Transfert effectué",
},
close: "Fermer",
banner: {
concerned_patient: "Patient concerné",
caller: "Appelant",
speaker: "Intervenant",
open: "Ouvert",
since: "Aucun jour | Depuis 1 jour | Depuis {count} jours",
no_motive: "Pas de motif",
},
},
};
Object.assign(messages.fr, multiSelectMessages.fr);
Object.assign(messages.fr, personMessages.fr);
export default messages;

View File

@ -2,12 +2,12 @@ import App from './App.vue';
import {createApp} from "vue";
import { _createI18n } from "../../../../../../ChillMainBundle/Resources/public/vuejs/_js/i18n";
import {messages} from "./i18n/messages";
import VueToast from 'vue-toast-notification';
import 'vue-toast-notification/dist/theme-sugar.css';
import { store } from "./store";
import messages from './i18n/messages';
declare global {
interface Window {
@ -15,7 +15,7 @@ declare global {
}
}
const i18n = _createI18n(messages);
const i18n = _createI18n(messages, false);
const _app = createApp({
template: '<app></app>',

View File

@ -1,15 +1,22 @@
import { createStore } from "vuex";
import { State as MotiveStates, moduleMotive } from "./modules/motive";
import { State as TicketStates, moduleTicket } from "./modules/ticket";
import { State as CommentStates, moduleComment } from "./modules/comment";
import { State as UserStates, moduleUser } from "./modules/user";
export type RootState = {
motive: MotiveStates;
ticket: TicketStates;
comment: CommentStates;
user: UserStates;
};
export const store = createStore({
modules: {
motive:moduleMotive,
ticket:moduleTicket,
comment:moduleComment,
user:moduleUser,
}
});

View File

@ -0,0 +1,40 @@
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
import { Comment } from "../../../../types";
export interface State {
comments: Array<Comment>;
}
export const moduleComment: Module<State, RootState> = {
state: () => ({
comments: [] as Array<Comment>,
}),
getters: {},
mutations: {},
actions: {
async createComment(
{ commit },
datas: { ticketId: number; content: Comment["content"] }
) {
const { ticketId, content } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/comment/add`,
{ content }
);
commit("setTicket", result);
}
catch(e: any) {
throw e.name;
}
},
},
};

View File

@ -1,4 +1,7 @@
import { fetchResults, makeFetch } from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
@ -9,31 +12,52 @@ export interface State {
motives: Array<Motive>;
}
export const moduleMotive: Module<State, RootState> ={
export const moduleMotive: Module<State, RootState> = {
state: () => ({
motives: [] as Array<Motive>,
}),
getters: {
getMotives(state) {
return state.motives;
}
},
},
mutations: {
setMotives(state, motives) {
state.motives = motives;
}
},
},
actions: {
async fetchMotives({ commit }) {
const results = await fetchResults("/api/1.0/ticket/motive.json") as Motive[];
commit("setMotives", results);
return results;
try {
const results = (await fetchResults(
"/api/1.0/ticket/motive.json"
)) as Motive[];
commit("setMotives", results);
} catch (e: any) {
throw e.name;
}
},
async createMotive(
{ commit },
datas: { ticketId: number; motive: Motive }
) {
const { ticketId, motive } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/motive/set`,
{
motive: {
id: motive.id,
type: motive.type,
},
}
);
commit("setTicket", result);
} catch (e: any) {
throw e.name;
}
},
async createMotive({ commit }, datas: {currentMotiveId: number, motive: Motive}) {
const { currentMotiveId, motive } = datas;
const result = await makeFetch("POST", `/api/1.0/ticket/${currentMotiveId}/motive/set`, motive);
commit("setMotives", result);
return result;
}
},
};

View File

@ -1,5 +1,3 @@
import { fetchResults, makeFetch } from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
@ -16,7 +14,24 @@ export const moduleTicket: Module<State, RootState> ={
getters: {
getTicket(state) {
return state.ticket;
}
},
getDistinctAddressesHistory(state) {
const addresseeHistory = state.ticket.history.reduce((result, item) => {
const { datetime } = item.at;
if (!["add_addressee","remove_addressee"].includes(item.event_type)) {
result[datetime] = item
return result;
}
if (!result[datetime]) {
result[datetime] = [];
}
result[datetime].push(item);
return result;
}, {} as any);
return Object.values(addresseeHistory) as Array<Ticket["history"]>;
}
},
mutations: {
setTicket(state, ticket) {

View File

@ -0,0 +1,84 @@
import {
fetchResults,
makeFetch,
} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods";
import { Module } from "vuex";
import { RootState } from "..";
import {
User,
UserGroup,
UserGroupOrUser,
} from "../../../../../../../../ChillMainBundle/Resources/public/types";
export interface State {
userGroups: Array<UserGroup>;
users: Array<User>;
}
export const moduleUser: Module<State, RootState> = {
state: () => ({
userGroups: [] as Array<UserGroup>,
users: [] as Array<User>,
}),
getters: {
getUserGroups(state) {
return state.userGroups;
},
getUsers(state) {
return state.users;
},
},
mutations: {
setUserGroups(state, userGroups) {
state.userGroups = userGroups;
},
setUsers(state, users) {
state.users = users;
},
},
actions: {
fetchUserGroups({ commit }) {
try {
fetchResults("/api/1.0/main/user-group.json").then(
(results) => {
commit("setUserGroups", results);
}
);
} catch (e: any) {
throw e.name;
}
},
fetchUsers({ commit }) {
try {
fetchResults("/api/1.0/main/user.json").then((results) => {
commit("setUsers", results);
});
} catch (e: any) {
throw e.name;
}
},
async setAdressees(
{ commit },
datas: { ticketId: number; addressees: Array<UserGroupOrUser> }
) {
const { ticketId, addressees } = datas;
try {
const result = await makeFetch(
"POST",
`/api/1.0/ticket/${ticketId}/addressees/set`,
{
addressees: addressees.map((addressee) => {
return { id: addressee.id, type: addressee.type };
}),
}
);
commit("setTicket", result);
} catch (e: any) {
throw e.name;
}
},
},
};

View File

@ -1,24 +1,8 @@
<div class="banner banner-ticket">
<div id="header-ticket-main" class="header-name">
<div class="container-xxl">
<div class="row">
<div class="col-md-6 ps-md-5 ps-xxl-0">
TODO
</div>
<div class="banner banner-ticket ">
<div id="header-ticket-main" class="header-name">
<div class="col-md-6">
TODO
</div>
</div>
</div>
</div>
<div id="header-ticket-details" class="header-details">
<div class="container-xxl">
<div class="row justify-content-between">
<div class="col-md-12 ps-md-5 ps-xxl-0 container">
<p>TODO</p>
</div>
</div>
</div>
</div>
</div>
<div id="header-ticket-details" class="header-details">
</div>
</div>

View File

@ -13,9 +13,5 @@
{% endblock %}
{% block wrapping_content %}
<div class="row">
<div class="col-md-8 col-sm-12">
{% block content %}{% endblock %}
</div>
</div>
{% block content %}{% endblock %}
{% endblock %}

View File

@ -42,6 +42,8 @@ final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInte
'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']),
'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => 'read']),
'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])),
'createdAt' => $object->getCreatedAt(),
'updatedBy' => $object->getUpdatedBy(),
];
}