index.vue 16 KB

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