index.vue 15 KB

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