index.vue 16 KB

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