index.vue 19 KB

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