index.vue 21 KB

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