index.vue 21 KB

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