index.vue 17 KB

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