index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. <!-- Search for member structures -->
  2. <template>
  3. <LayoutContainer>
  4. <!-- Header -->
  5. <v-row>
  6. <v-layout>
  7. <h2 class="flex">
  8. {{ $t("member_companies") }}
  9. </h2>
  10. <v-btn-toggle mandatory dense :value="mapview ? 0 : 1" @change="viewChanged">
  11. <v-btn>
  12. {{ $t("map") }}
  13. </v-btn>
  14. <v-btn>
  15. {{ $t("list") }}
  16. </v-btn>
  17. </v-btn-toggle>
  18. </v-layout>
  19. </v-row>
  20. <v-row>
  21. <!-- Map Column (hidden in 'list-view' mode)-->
  22. <v-col
  23. v-if="mapview"
  24. cols="12"
  25. sm="6"
  26. >
  27. <no-ssr>
  28. <UiMapStructures
  29. ref="map"
  30. :structures="filteredStructures"
  31. :zoom-at-start="mapZoomAtStart"
  32. @boundsUpdated="mapBoundsFilterChanged"
  33. />
  34. </no-ssr>
  35. </v-col>
  36. <!-- Results column -->
  37. <v-col
  38. cols="12"
  39. :sm="mapview ? 6 : 12"
  40. >
  41. <!-- Search form -->
  42. <v-row>
  43. <v-form method="get" class="mt-8 w100">
  44. <v-container>
  45. <v-row>
  46. <v-col cols="12" md="6" class="py-2 px-1">
  47. <v-text-field
  48. v-model="textFilter"
  49. type="text"
  50. outlined
  51. clearable
  52. hide-details
  53. append-icon="mdi-magnify"
  54. :label="$t('what') + ' ?'"
  55. @click:append="search"
  56. @keydown.enter="search"
  57. />
  58. </v-col>
  59. <v-col cols="12" md="6" class="py-2 px-1">
  60. <UiSearchAddress
  61. ref="addressSearch"
  62. type="municipality"
  63. @change="locationFilterChanged"
  64. />
  65. </v-col>
  66. </v-row>
  67. <v-row>
  68. <v-col v-if="listview && $vuetify.breakpoint.mdAndUp" cols="2" class="py-2 px-1">
  69. <v-btn class="h100" @click="reinitializeFilters">
  70. {{ $t('reinitialize') }}
  71. </v-btn>
  72. </v-col>
  73. <v-col :cols="(listview && $vuetify.breakpoint.mdAndUp) ? 8 : 12">
  74. <v-row class="filters">
  75. <v-col lg="3" :md="listview ? 3 : 6" sm="6" cols="12" class="py-2 px-1">
  76. <v-autocomplete
  77. v-model="practicesFilter"
  78. :label="$t('type')"
  79. :items="translatedPractices"
  80. item-value="id"
  81. item-text="label"
  82. filled
  83. hide-details
  84. hide-no-data
  85. :filter="enhancedAutocompleteFilter"
  86. @change="search"
  87. />
  88. </v-col>
  89. <v-col lg="3" :md="listview ? 3 : 6" sm="6" cols="12" class="py-2 px-1">
  90. <v-autocomplete
  91. v-model="departmentFilter"
  92. :items="departments"
  93. item-value="code"
  94. item-text="label"
  95. :label="$t('department')"
  96. filled
  97. hide-details
  98. hide-no-data
  99. :filter="enhancedAutocompleteFilter"
  100. @change="search"
  101. />
  102. </v-col>
  103. <v-col lg="3" :md="listview ? 3 : 6" sm="6" cols="12" class="py-2 px-1">
  104. <v-autocomplete
  105. v-model="federationFilter"
  106. :items="federations"
  107. item-value="id"
  108. item-text="name"
  109. :label="$t('federation')"
  110. filled
  111. hide-details
  112. hide-no-data
  113. :filter="enhancedAutocompleteFilter"
  114. @change="search"
  115. />
  116. </v-col>
  117. <v-col lg="3" :md="listview ? 3 : 6" sm="6" cols="12" class="py-2 px-1">
  118. <v-select
  119. v-model="distanceFilter"
  120. :label="$t('distance')"
  121. :items="[
  122. {distance: 10, label: '10km'},
  123. {distance: 30, label: '30km'},
  124. {distance: 100, label: '100km'},
  125. {distance: 200, label: '200km'}
  126. ]"
  127. item-value="distance"
  128. item-text="label"
  129. filled
  130. hide-details
  131. @change="search"
  132. />
  133. </v-col>
  134. </v-row>
  135. </v-col>
  136. <v-col v-if="listview && $vuetify.breakpoint.mdAndUp" cols="2" class="py-2 px-1 d-flex justify-end">
  137. <v-btn class="h100">
  138. {{ $t('search') }}
  139. </v-btn>
  140. </v-col>
  141. </v-row>
  142. <v-row v-show="mapview || $vuetify.breakpoint.smAndDown" class="px-2 pt-2">
  143. <v-btn @click="reinitializeFilters">
  144. {{ $t('reinitialize') }}
  145. </v-btn>
  146. <v-spacer />
  147. <v-btn @click="search">
  148. {{ $t('search') }}
  149. </v-btn>
  150. </v-row>
  151. </v-container>
  152. </v-form>
  153. </v-row>
  154. <div class="pt-4 mt-6">
  155. <!-- loading skeleton -->
  156. <v-container v-if="$fetchState.pending">
  157. <v-row v-for="i in 3" :key="i" justify="space-between" class="mt-1 mb-3">
  158. <v-col v-for="j in 2" :key="j" cols="12" :md="mapview ? 6 : 12" class="py-2 px-1">
  159. <v-skeleton-loader type="card" :loading="true" />
  160. </v-col>
  161. </v-row>
  162. </v-container>
  163. <!-- Results -->
  164. <v-data-iterator
  165. v-else
  166. :items="onMapFilteredStructures"
  167. :page.sync="page"
  168. :items-per-page="itemsPerPage"
  169. sort-by="name"
  170. hide-default-footer
  171. no-data-text=""
  172. >
  173. <template #header>
  174. <i class="results-count">{{ totalRecords }} {{ $t('results') }}</i>
  175. </template>
  176. <template #default="props">
  177. <v-row justify="space-between" class="mt-1 mb-3">
  178. <v-col
  179. v-for="structure in props.items"
  180. :key="structure.name"
  181. cols="12"
  182. sm="12"
  183. :lg="mapview ? 6 : 12"
  184. class="py-2 px-1"
  185. >
  186. <v-card
  187. elevation="1"
  188. outlined
  189. :class="'structure-card pa-3 d-flex ' + ((mapview || $vuetify.breakpoint.smAndDown) ? 'flex-column' : 'flex-row align-items-center')"
  190. >
  191. <div class="d-flex justify-center max-w100" >
  192. <v-img
  193. v-if="structure.logoId"
  194. :src="'https://api.opentalent.fr/app.php/_internal/secure/files/' + structure.logoId"
  195. alt="poster"
  196. height="80"
  197. width="240"
  198. max-height="100%"
  199. :contain="true"
  200. style="margin: 12px;"
  201. />
  202. <v-skeleton-loader
  203. v-else
  204. type="card-avatar"
  205. boilerplate
  206. width="164px"
  207. height="84px"
  208. style="margin: 10px 50px;"
  209. />
  210. </div>
  211. <div :class="'d-flex flex-column' + (listview ? ' flex-grow-1' : '')">
  212. <v-card-title class="title">
  213. <nuxt-link :to="{path: '/structures/' + structure.id, query: { parent: parent, view: view, theme: theme }}">
  214. {{ structure.name }}
  215. </nuxt-link>
  216. </v-card-title>
  217. <v-card-text class="infos">
  218. <table>
  219. <tr>
  220. <td>
  221. <font-awesome-icon class="icon" :icon="['fas', 'map-marker-alt']" />
  222. </td>
  223. <td>
  224. <span v-if="structure.streetAddress">{{ structure.streetAddress }}<br></span>
  225. <span v-if="structure.postalCode" class="postalCode">{{ structure.postalCode }} </span>
  226. {{ structure.addressCity }}
  227. </td>
  228. </tr>
  229. <tr>
  230. <td>
  231. <font-awesome-icon class="icon" :icon="['fas', 'project-diagram']" />
  232. </td>
  233. <td>
  234. <NuxtLink
  235. v-if="structure.n1Id !== parent"
  236. class="neutral"
  237. :to="{path: '/structures/' + structure.n1Id, query: { parent: parent, view: view, theme: theme }}"
  238. nuxt
  239. >
  240. {{ structure.n1Name }}
  241. </NuxtLink>
  242. <div v-else>
  243. {{ structure.n1Name }}
  244. </div>
  245. </td>
  246. </tr>
  247. </table>
  248. </v-card-text>
  249. </div>
  250. <div
  251. v-if="structure.practices"
  252. class="d-grid flex-wrap"
  253. :style="listview ? 'width: 25%;min-width: 25%;' : ''"
  254. >
  255. <v-chip
  256. v-for="practice in structure.practices"
  257. :key="practice"
  258. class="ma-1"
  259. label
  260. small
  261. >
  262. {{ $t(practice) }}
  263. </v-chip>
  264. </div>
  265. <span v-if="mapview" class="flex-fill" />
  266. <v-card-actions :class="listview ? 'align-self-end' : ''">
  267. <v-btn
  268. class="see"
  269. :to="{path: '/structures/' + structure.id, query: { parent: parent, view: view, theme: theme }}"
  270. nuxt
  271. >
  272. <span style="margin-right: 6px;">{{ $t("see_more") }}</span>
  273. <font-awesome-icon :icon="['fa', 'caret-right']" />
  274. </v-btn>
  275. </v-card-actions>
  276. </v-card>
  277. </v-col>
  278. </v-row>
  279. </template>
  280. <template #footer>
  281. <v-pagination
  282. v-model="page"
  283. :length="pageCount"
  284. total-visible="9"
  285. color="primary"
  286. />
  287. </template>
  288. </v-data-iterator>
  289. </div>
  290. </v-col>
  291. </v-row>
  292. </LayoutContainer>
  293. </template>
  294. <script lang="ts">
  295. import Vue from 'vue'
  296. import { LatLngBounds } from 'leaflet'
  297. import departments from '@/enums/departments'
  298. import practices from '@/enums/practices'
  299. import sphericDistance from '@/services/utils/geo'
  300. import StructuresProvider from '~/services/data/StructuresProvider'
  301. const CMF_ID = 12097
  302. export default Vue.extend({
  303. validate ({ query }) {
  304. if (!/^\d+$/.test(query.parent as string ?? '')) {
  305. // eslint-disable-next-line no-console
  306. console.error('Missing parameter: parent')
  307. return false
  308. }
  309. if (query.view && !['map', 'list'].includes(query.view as string)) {
  310. // eslint-disable-next-line no-console
  311. console.error('Invalid parameter: view')
  312. return false
  313. }
  314. return true
  315. },
  316. data () {
  317. return {
  318. parent: parseInt(this.$route.query.parent as string),
  319. view: this.$route.query.view ?? 'map',
  320. theme: this.$route.query.theme ?? 'orange',
  321. structures: [] as Array<Structure>,
  322. filteredStructures: [] as Array<Structure>,
  323. federations: [] as Array<{ id: number | null, name: string | null }>,
  324. loading: true,
  325. page: 1,
  326. itemsPerPage: 8,
  327. departments: departments as {code: string, label: string}[],
  328. practices: practices as {id: string}[],
  329. textFilter: null as string | null,
  330. locationFilter: null as Coordinates | null,
  331. practicesFilter: null as string | null,
  332. departmentFilter: null as string | null,
  333. federationFilter: null as number | null,
  334. distanceFilter: null as number | null,
  335. mapBoundsFilter: null as LatLngBounds | null,
  336. mapBoundsFilterStarted: false, // map bounds filter is only activated when the map bounds are updated
  337. mapZoomAtStart: false
  338. }
  339. },
  340. async fetch () {
  341. await new StructuresProvider(this.$axios).getAll(this.parent).then(
  342. (res) => {
  343. this.structures = res
  344. this.filteredStructures = res
  345. // populate federations filter
  346. for (const s of res) {
  347. const f = {
  348. id: s.n1Id,
  349. name: s.n1Name
  350. }
  351. if (!this.federations.includes(f)) {
  352. this.federations.push(f)
  353. }
  354. }
  355. // zoom on map markers (except if the parent structure is the CMF)
  356. if (this.parent !== CMF_ID) {
  357. this.mapZoomAtStart = true
  358. }
  359. })
  360. },
  361. computed: {
  362. onMapFilteredStructures (): Array<Structure> {
  363. if (this.mapview && this.mapBoundsFilterStarted) {
  364. return this.filteredStructures.filter((s) => {
  365. return this.matchMapBounds(s)
  366. })
  367. } else {
  368. return this.filteredStructures
  369. }
  370. },
  371. totalRecords (): number {
  372. return this.onMapFilteredStructures.length
  373. },
  374. pageCount (): number {
  375. return Math.floor(this.totalRecords / this.itemsPerPage) + 1
  376. },
  377. mapview (): boolean {
  378. return this.view === 'map'
  379. },
  380. listview (): boolean {
  381. return this.view === 'list'
  382. },
  383. translatedPractices (): Array<{ id: string, label: string }> {
  384. const tPractices = []
  385. for (const practice of this.practices) {
  386. tPractices.push({ id: practice.id, label: this.$t(practice.id) as string })
  387. }
  388. return tPractices
  389. }
  390. },
  391. methods: {
  392. viewChanged (e: number) {
  393. this.view = (e === 0) ? 'map' : 'list'
  394. if (this.mapview) {
  395. this.mapZoomAtStart = true
  396. }
  397. },
  398. textFilterChanged (newVal: string) {
  399. this.textFilter = newVal
  400. },
  401. locationFilterChanged (newVal: Coordinates) {
  402. this.locationFilter = newVal
  403. if (this.distanceFilter === null) {
  404. this.distanceFilter = 10
  405. }
  406. this.search()
  407. },
  408. resetMap (): void {
  409. const map = this.$refs.map as any
  410. map.resetBounds()
  411. if (this.parent !== CMF_ID) {
  412. this.fitMapToResults()
  413. }
  414. this.mapBoundsFilterStarted = false
  415. },
  416. fitMapToResults (): void {
  417. (this.$refs.map as any).zoomOnResults()
  418. },
  419. mapBoundsFilterChanged (newBounds: LatLngBounds) {
  420. this.mapBoundsFilterStarted = true
  421. this.mapBoundsFilter = newBounds
  422. },
  423. reinitializeFilters (): void {
  424. this.textFilter = null
  425. this.locationFilter = null
  426. this.practicesFilter = null
  427. this.departmentFilter = null
  428. this.federationFilter = null
  429. this.distanceFilter = null
  430. this.mapBoundsFilter = null
  431. const addressSearch = this.$refs.addressSearch as any
  432. addressSearch.clear()
  433. this.resetMap()
  434. this.filteredStructures = this.structures
  435. },
  436. /**
  437. * Does the structure match the current textFilter
  438. * @param structure
  439. * @returns {boolean}
  440. */
  441. matchTextFilter (structure: Structure): boolean {
  442. if (!this.textFilter) { return true }
  443. return this.searchTextNormalize(structure.name).includes(this.searchTextNormalize(this.textFilter))
  444. },
  445. /**
  446. * Does the structure match the current locationFilter
  447. * @param structure
  448. * @returns {boolean}
  449. */
  450. matchLocationFilter (structure: Structure): boolean {
  451. if (!this.locationFilter) { return true }
  452. if (!structure.latitude || !structure.longitude) { return false }
  453. const radius = Number(this.distanceFilter) ?? 5
  454. return sphericDistance(
  455. this.locationFilter.latitude, this.locationFilter.longitude, structure.latitude, structure.longitude
  456. ) <= radius
  457. },
  458. /**
  459. * Does the structure match the current practicesFilter
  460. * @param structure
  461. * @returns {boolean}
  462. */
  463. matchPracticesFilter (structure: Structure): boolean {
  464. if (!this.practicesFilter) { return true }
  465. return structure.practices && structure.practices.includes(this.practicesFilter)
  466. },
  467. /**
  468. * Does the structure match the current departmentFilter
  469. * @param structure
  470. * @returns {boolean}
  471. */
  472. matchDepartmentFilter (structure: Structure): boolean {
  473. if (!this.departmentFilter) { return true }
  474. return structure.postalCode !== null &&
  475. (
  476. structure.postalCode.startsWith(this.departmentFilter) ||
  477. (['2A', '2B'].includes(this.departmentFilter) && structure.postalCode.startsWith('20'))
  478. )
  479. },
  480. /**
  481. * Does the structure match the current federationFilter
  482. * @param structure
  483. * @returns {boolean}
  484. */
  485. matchFederationFilter (structure: Structure): boolean {
  486. if (!this.federationFilter) { return true }
  487. return structure.parents.includes(this.federationFilter)
  488. },
  489. /**
  490. * Does the structure match the current federationFilter
  491. * @param structure
  492. * @returns {boolean}
  493. */
  494. matchMapBounds (structure: Structure): boolean {
  495. if (!this.mapBoundsFilter) { return true }
  496. if (!(structure.latitude && structure.longitude)) { return false }
  497. return this.mapBoundsFilter.getSouth() <= structure.latitude &&
  498. structure.latitude <= this.mapBoundsFilter.getNorth() &&
  499. this.mapBoundsFilter.getWest() <= structure.longitude &&
  500. structure.longitude <= this.mapBoundsFilter.getEast()
  501. },
  502. /**
  503. * Does the structure match each of the page filters
  504. * @param structure
  505. * @returns {boolean}
  506. */
  507. matchFilters (structure: Structure): boolean {
  508. return this.matchTextFilter(structure) &&
  509. this.matchLocationFilter(structure) &&
  510. this.matchPracticesFilter(structure) &&
  511. this.matchDepartmentFilter(structure) &&
  512. this.matchFederationFilter(structure)
  513. },
  514. /**
  515. * Update the filteredStructures array
  516. */
  517. search (): void {
  518. this.filteredStructures = this.structures.filter((s) => { return this.matchFilters(s) })
  519. if (this.mapview) {
  520. this.fitMapToResults()
  521. }
  522. },
  523. searchTextNormalize (s: string): string {
  524. return s
  525. .toLowerCase()
  526. .replace(/[éèẽëê]/g, 'e')
  527. .replace(/[ç]/g, 'c')
  528. .replace(/[îïĩ]/g, 'i')
  529. .replace(/[àã]/g, 'a')
  530. .replace(/[öôõ]/g, 'o')
  531. .replace(/[ûüũ]/g, 'u')
  532. .replace(/[-]/g, ' ')
  533. .trim()
  534. },
  535. /**
  536. * Enhanced filter for v-autocomplete components
  537. *
  538. * @param _
  539. * @param queryText
  540. * @param itemText
  541. */
  542. enhancedAutocompleteFilter (_: any, queryText: string, itemText: string): boolean {
  543. return this.searchTextNormalize(itemText).includes(this.searchTextNormalize(queryText))
  544. }
  545. }
  546. })
  547. </script>
  548. <style scoped lang="scss">
  549. @import 'assets/style/variables.scss';
  550. h2 {
  551. color: var(--v-primary-base);
  552. }
  553. .structure-card {
  554. height: 100%;
  555. color: #666666;
  556. }
  557. .infos .col {
  558. padding: 6px 12px;
  559. }
  560. .infos td {
  561. padding: 4px;
  562. vertical-align: top;
  563. }
  564. .infos td:first-child {
  565. padding-top: 6px;
  566. text-align: center;
  567. }
  568. .title {
  569. word-break: normal;
  570. color: var(--v-primary-base);
  571. font-size: 18px;
  572. font-weight: 500;
  573. line-height: 1.6rem;
  574. }
  575. .title a {
  576. text-decoration: none;
  577. }
  578. .icon {
  579. color: var(--v-primary-base);
  580. }
  581. .results-count {
  582. font-size: .8em;
  583. color: #666;
  584. }
  585. </style>