index.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <!-- Search for member structures -->
  2. <template>
  3. <LayoutContainer>
  4. <!-- Header -->
  5. <v-row>
  6. <v-layout>
  7. <h2 v-show="!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"></div>
  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>
  215. <td>
  216. <font-awesome-icon class="icon" :icon="['fas', 'map-marker-alt']" />
  217. </td>
  218. <td>
  219. <span v-if="structure.streetAddress">{{ structure.streetAddress }}<br></span>
  220. <span v-if="structure.postalCode" class="postalCode">{{ structure.postalCode }} </span>
  221. {{ structure.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 mx-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. console.log(this.$route.query)
  325. return {
  326. parent: parseInt(this.$route.query.parent as string),
  327. view: this.$route.query.view ?? 'map',
  328. theme: this.$route.query.theme ?? 'orange',
  329. hideTitle: this.$route.query.hideTitle ?? false,
  330. structures: [] as Array<Structure>,
  331. filteredStructures: [] as Array<Structure>,
  332. federations: [] as Array<{ id: number | null, name: string | null }>,
  333. loading: true,
  334. page: 1,
  335. itemsPerPage: 8,
  336. departments: departments as {code: string, label: string}[],
  337. practices: practices as {id: string}[],
  338. textFilter: null as string | null,
  339. locationFilter: null as Coordinates | null,
  340. practicesFilter: null as string | null,
  341. departmentFilter: null as string | null,
  342. federationFilter: null as number | null,
  343. distanceFilter: null as number | null,
  344. mapBoundsFilter: null as LatLngBounds | null,
  345. mapBoundsFilterStarted: false, // map bounds filter is only activated when the map bounds are updated
  346. mapZoomExpected: false
  347. }
  348. },
  349. async fetch () {
  350. await new StructuresProvider(this.$axios).getAll(this.parent).then(
  351. (res) => {
  352. this.structures = res
  353. this.filteredStructures = res
  354. // populate federations filter
  355. for (const s of res) {
  356. const f = {
  357. id: s.n1Id,
  358. name: s.n1Name
  359. }
  360. if (!this.federations.includes(f)) {
  361. this.federations.push(f)
  362. }
  363. }
  364. })
  365. },
  366. computed: {
  367. parentIsCmf (): Boolean {
  368. return this.parent === CMF_ID
  369. },
  370. cmfStructuresPageUrl (): string {
  371. return CMF_STRUCTURES_PAGE_URL
  372. },
  373. onMapFilteredStructures (): Array<Structure> {
  374. if (this.mapview && this.mapBoundsFilterStarted) {
  375. return this.filteredStructures.filter((s) => {
  376. return this.matchMapBounds(s)
  377. })
  378. } else {
  379. return this.filteredStructures
  380. }
  381. },
  382. totalRecords (): number {
  383. return this.onMapFilteredStructures.length
  384. },
  385. pageCount (): number {
  386. return Math.floor(this.totalRecords / this.itemsPerPage) + 1
  387. },
  388. mapview (): boolean {
  389. return this.view === 'map'
  390. },
  391. listview (): boolean {
  392. return this.view === 'list'
  393. },
  394. translatedPractices (): Array<{ id: string, label: string }> {
  395. const tPractices = []
  396. for (const practice of this.practices) {
  397. tPractices.push({ id: practice.id, label: this.$t(practice.id) as string })
  398. }
  399. return tPractices
  400. }
  401. },
  402. methods: {
  403. viewChanged (e: number) {
  404. this.view = (e === 0) ? 'map' : 'list'
  405. },
  406. mapMounted () {
  407. // zoom on map markers and set it as the default view (except if the parent structure is the CMF)
  408. if (!this.parentIsCmf) {
  409. this.fitMapToResults(true)
  410. }
  411. },
  412. mapPopulated () {
  413. // zoom on map markers (except if the parent structure is the CMF)
  414. if (!this.parentIsCmf) {
  415. this.fitMapToResults()
  416. }
  417. },
  418. textFilterChanged (newVal: string) {
  419. this.textFilter = newVal
  420. },
  421. locationFilterChanged (newVal: Coordinates) {
  422. this.locationFilter = newVal
  423. if (this.distanceFilter === null) {
  424. this.distanceFilter = 10
  425. }
  426. this.search()
  427. },
  428. resetMap (): void {
  429. (this.$refs.map as any).resetBounds()
  430. this.mapBoundsFilterStarted = false
  431. },
  432. fitMapToResults (setAsDefault = false): void {
  433. (this.$refs.map as any).fitNextResults(setAsDefault)
  434. },
  435. mapBoundsFilterChanged (newBounds: LatLngBounds) {
  436. this.mapBoundsFilterStarted = true
  437. this.mapBoundsFilter = newBounds
  438. },
  439. reinitializeFilters (): void {
  440. this.textFilter = null
  441. this.locationFilter = null
  442. this.practicesFilter = null
  443. this.departmentFilter = null
  444. this.federationFilter = null
  445. this.distanceFilter = null
  446. this.mapBoundsFilter = null
  447. const addressSearch = this.$refs.addressSearch as any
  448. addressSearch.clear()
  449. if (this.mapview) {
  450. this.resetMap()
  451. }
  452. this.filteredStructures = this.structures
  453. },
  454. /**
  455. * Does the structure match the current textFilter
  456. * @param structure
  457. * @returns {boolean}
  458. */
  459. matchTextFilter (structure: Structure): boolean {
  460. if (!this.textFilter) { return true }
  461. return this.searchTextNormalize(structure.name).includes(this.searchTextNormalize(this.textFilter))
  462. },
  463. /**
  464. * Does the structure match the current locationFilter
  465. * @param structure
  466. * @returns {boolean}
  467. */
  468. matchLocationFilter (structure: Structure): boolean {
  469. if (!this.locationFilter) { return true }
  470. if (!structure.latitude || !structure.longitude) { return false }
  471. const radius = Number(this.distanceFilter) ?? 5
  472. return sphericDistance(
  473. this.locationFilter.latitude, this.locationFilter.longitude, structure.latitude, structure.longitude
  474. ) <= radius
  475. },
  476. /**
  477. * Does the structure match the current practicesFilter
  478. * @param structure
  479. * @returns {boolean}
  480. */
  481. matchPracticesFilter (structure: Structure): boolean {
  482. if (!this.practicesFilter) { return true }
  483. return structure.practices && structure.practices.includes(this.practicesFilter)
  484. },
  485. /**
  486. * Does the structure match the current departmentFilter
  487. * @param structure
  488. * @returns {boolean}
  489. */
  490. matchDepartmentFilter (structure: Structure): boolean {
  491. if (!this.departmentFilter) { return true }
  492. return structure.postalCode !== null &&
  493. (
  494. structure.postalCode.startsWith(this.departmentFilter) ||
  495. (['2A', '2B'].includes(this.departmentFilter) && structure.postalCode.startsWith('20'))
  496. )
  497. },
  498. /**
  499. * Does the structure match the current federationFilter
  500. * @param structure
  501. * @returns {boolean}
  502. */
  503. matchFederationFilter (structure: Structure): boolean {
  504. if (!this.federationFilter) { return true }
  505. return structure.parents.includes(this.federationFilter)
  506. },
  507. /**
  508. * Does the structure match the current federationFilter
  509. * @param structure
  510. * @returns {boolean}
  511. */
  512. matchMapBounds (structure: Structure): boolean {
  513. if (!this.mapBoundsFilter) { return true }
  514. if (!(structure.latitude && structure.longitude)) { return false }
  515. return this.mapBoundsFilter.getSouth() <= structure.latitude &&
  516. structure.latitude <= this.mapBoundsFilter.getNorth() &&
  517. this.mapBoundsFilter.getWest() <= structure.longitude &&
  518. structure.longitude <= this.mapBoundsFilter.getEast()
  519. },
  520. /**
  521. * Does the structure match each of the page filters
  522. * @param structure
  523. * @returns {boolean}
  524. */
  525. matchFilters (structure: Structure): boolean {
  526. return this.matchTextFilter(structure) &&
  527. this.matchLocationFilter(structure) &&
  528. this.matchPracticesFilter(structure) &&
  529. this.matchDepartmentFilter(structure) &&
  530. this.matchFederationFilter(structure)
  531. },
  532. /**
  533. * Update the filteredStructures array
  534. */
  535. search (): void {
  536. this.filteredStructures = this.structures.filter((s) => { return this.matchFilters(s) })
  537. if (this.mapview) {
  538. this.fitMapToResults()
  539. }
  540. },
  541. searchTextNormalize (s: string): string {
  542. return s
  543. .toLowerCase()
  544. .replace(/[éèẽëê]/g, 'e')
  545. .replace(/[ç]/g, 'c')
  546. .replace(/[îïĩ]/g, 'i')
  547. .replace(/[àã]/g, 'a')
  548. .replace(/[öôõ]/g, 'o')
  549. .replace(/[ûüũ]/g, 'u')
  550. .replace(/[-]/g, ' ')
  551. .trim()
  552. },
  553. /**
  554. * Enhanced filter for v-autocomplete components
  555. *
  556. * @param _
  557. * @param queryText
  558. * @param itemText
  559. */
  560. enhancedAutocompleteFilter (_: any, queryText: string, itemText: string): boolean {
  561. return this.searchTextNormalize(itemText).includes(this.searchTextNormalize(queryText))
  562. }
  563. }
  564. })
  565. </script>
  566. <style scoped lang="scss">
  567. @import 'assets/style/variables.scss';
  568. h2 {
  569. color: var(--v-primary-base);
  570. }
  571. .structure-card {
  572. height: 100%;
  573. color: #666666;
  574. }
  575. .infos .col {
  576. padding: 6px 12px;
  577. }
  578. .infos td {
  579. padding: 4px;
  580. vertical-align: top;
  581. }
  582. .infos td:first-child {
  583. padding-top: 6px;
  584. text-align: center;
  585. }
  586. .title {
  587. word-break: normal;
  588. color: var(--v-primary-base);
  589. font-size: 18px;
  590. font-weight: 500;
  591. line-height: 1.6rem;
  592. }
  593. .title a {
  594. text-decoration: none;
  595. }
  596. .icon {
  597. color: var(--v-primary-base);
  598. }
  599. .results-count {
  600. font-size: .8em;
  601. color: #666;
  602. }
  603. </style>