diff --git a/proxy.conf.json b/proxy.conf.json index 9f8b1d711d50b0708424695c0ef15184cb81ff8f..48ad4e75599947ce95d9bb9cf2ea4d7e864dc9d7 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -28,5 +28,11 @@ "secure": false, "changeOrigin": true, "logLevel": "info" + }, + "/fr/datapusher": { + "target": "https://data.grandlyon.com", + "secure": false, + "changeOrigin": true, + "logLevel": "info" } } diff --git a/src/app/annuaire/annuaire-header/annuaire-header.component.ts b/src/app/annuaire/annuaire-header/annuaire-header.component.ts index d2003e803b46c972a30c7026036c476c6c12384b..3df6eea39ef9a7293f2426828db441c7526695dd 100644 --- a/src/app/annuaire/annuaire-header/annuaire-header.component.ts +++ b/src/app/annuaire/annuaire-header/annuaire-header.component.ts @@ -1,4 +1,4 @@ -import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { forkJoin, lastValueFrom } from 'rxjs'; import { Category } from '../../structure-list/models/category.model'; @@ -33,7 +33,6 @@ export class AnnuaireHeaderComponent implements OnInit, OnChanges { private activatedRoute: ActivatedRoute, private router: Router, public searchService: SearchService, - private elementRef: ElementRef, ) {} async ngOnInit(): Promise<void> { diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 7a14f9f9b08d21e2abc0adb89e3822081da9cb94..d7a1f3d19be757e2858289dfccbc412570265931 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -3,6 +3,7 @@ import { Route, RouterModule, Routes } from '@angular/router'; import { AnnuaireComponent } from './annuaire/annuaire.component'; import { CartoComponent } from './carto/carto.component'; import { ContactComponent } from './contact/contact.component'; +import { DataComponent } from './data/data.component'; import { FooterComponent } from './footer/footer.component'; import { AdminGuard } from './guards/admin.guard'; import { AuthGuard } from './guards/auth.guard'; @@ -15,6 +16,7 @@ import { ResetEmailComponent } from './reset-email/reset-email.component'; import { ForgotPasswordComponent } from './reset-password/forgot-password.component'; import { StructureResolver } from './resolvers/structure.resolver'; import { StructureDetailsComponent } from './structure-list/components/structure-details/structure-details.component'; +import { StructureListDataComponent } from './structure-list/components/structure-list-data/structure-list-data.component'; import { StructureListSearchPrintComponent } from './structure-list/components/structure-list-search-print/structure-list-search-print.component'; import { StructureListComponent } from './structure-list/structure-list.component'; import { StructureJoinComponent } from './structure/structure-join/structure-join.component'; @@ -219,6 +221,21 @@ const routes: Routes = [ footerOutletRoute, ], }, + { + path: 'data', + title: buildTitle(`Rés'in Data`), + children: [ + { + path: '', + component: DataComponent, + }, + footerOutletRoute, + { + path: 'structures', + component: StructureListDataComponent, + }, + ], + }, { path: 'actualites', title: buildTitle('Actualités'), diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bbc8094d35b488dcca9a81082d1a582daf4526b7..1e67572be845ace79a947544e64ac4fa9d0bd67d 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -16,6 +16,7 @@ import { CartoModule } from './carto/carto.module'; import { CustomBreakPointsProvider } from './config/custom-breakpoint'; import { CustomHttpInterceptor } from './config/http-interceptor'; import { ContactComponent } from './contact/contact.component'; +import { DataComponent } from './data/data.component'; import { FooterComponent } from './footer/footer.component'; import { FormViewModule } from './form/form-view/form-view.module'; import { OrientationModule } from './form/orientation-form-view/orientation.module'; @@ -40,6 +41,7 @@ import { UpdateService } from './services/update.service'; import { DataShareConsentComponent } from './shared/components/data-share-consent/data-share-consent.component'; import { SharedModule } from './shared/shared.module'; import { StructureDetailsComponent } from './structure-list/components/structure-details/structure-details.component'; +import { StructureListDataComponent } from './structure-list/components/structure-list-data/structure-list-data.component'; import { StructureListSearchPrintComponent } from './structure-list/components/structure-list-search-print/structure-list-search-print.component'; import { StructureJoinComponent } from './structure/structure-join/structure-join.component'; @@ -62,6 +64,8 @@ import { StructureJoinComponent } from './structure/structure-join/structure-joi AnnuaireHeaderComponent, ResultListComponent, StructureListSearchPrintComponent, + StructureListDataComponent, + DataComponent, ], imports: [ BrowserModule, diff --git a/src/app/data/data.component.html b/src/app/data/data.component.html new file mode 100644 index 0000000000000000000000000000000000000000..1cc9f4b95c8f5daf776054b0507bb5f6847400a0 --- /dev/null +++ b/src/app/data/data.component.html @@ -0,0 +1,133 @@ +<div class="data"> + <div class="main-container"> + <div class="title-container"> + <h2 class="title">Rés'in Data</h2> + <p class="subtitle">Les données essentielles à télécharger</p> + + <p> + Retrouvez ici les données présentes sur Rés’in, aux formats liste imprimable (pdf) et base de données (csv). + </p> + <p> + Vous avez besoin de données qui ne sont pas listées ci-dessous ? Écrivez-nous via la + <a href="/contact">page de contact</a>. + </p> + </div> + + <div class="filters-container isntPhoneContent"> + <h3>Pour générer des fichiers, merci de sélectionner vos paramètres</h3> + <div class="filters"> + <div class="row selects"> + <app-collapsable-filter + [label]="territorySelectLabel" + [categories]="categoriesTerritory" + [isRadio]="true" + [checkedModules]="checkedModulesTerritory" + (selectEvent)="setFilters($event)" + /> + <app-collapsable-filter + *ngIf="selectedOption === 'choice3'" + label="Sélectionnez un territoire" + [categories]="categoriesCTMs" + [checkedModules]="checkedModulesCTMs" + (selectEvent)="setFilters($event)" + /> + <app-collapsable-filter + *ngIf="selectedOption === 'choice4'" + label="Sélectionnez une commune" + [categories]="categoriesInseeCodes" + [checkedModules]="checkedModulesInseeCodes" + (selectEvent)="setFilters($event)" + /> + </div> + <div *ngIf="checkedModulesFilter.length" class="row tags isntPhoneContent"> + <div class="title">Filtres :</div> + <app-tag-item + *ngFor="let filter of checkedModulesFilter" + [ariaLabel]="'Supprimer filtre ' + filter" + [label]="filter.displayText" + [size]="'small'" + [color]="'grey'" + [iconName]="'cross'" + [iconPosition]="'right'" + [clickable]="true" + (action)="removeFilter(filter)" + /> + <app-tag-item + [label]="'Réinitialiser les filtres'" + [size]="'small'" + [color]="'white'" + [iconName]="'refresh'" + [iconPosition]="'right'" + [clickable]="true" + (action)="resetFilters()" + /> + </div> + </div> + </div> + <div class="content"> + <h3 class="result-title" *ngIf="checkedModulesFilterLabel !== ''"> + Vos données pour : <span class="territory">{{ checkedModulesFilterLabel }}</span> + </h3> + <div class="list"> + <div class="list-header"> + <p class="list-title">Lieux</p> + <p class="buttons-title" *ngIf="areButtonsVisible">Téléchargez (Max. 2 Mo)</p> + </div> + + <div *ngFor="let structure of structures" class="item"> + <div class="info-container"> + <div class="image"> + <img src="/assets/ico/mapMarker.svg" alt="" /> + </div> + <div class="name"> + {{ structure.name }} + </div> + </div> + <div class="downloads" *ngIf="areButtonsVisible"> + <app-button + variant="secondary" + size="small" + label="Format .csv" + iconName="download" + [disabled]="isDownloading" + (click)="downloadStructureCsv(structure)" + /> + <app-button + variant="secondary" + size="small" + label="Format .pdf" + iconName="download" + [disabled]="isDownloading" + (click)="downloadStructurePdf(structure)" + /> + </div> + </div> + </div> + + <div class="list"> + <p class="list-title">Membres</p> + <p class="list-subtitle">Utilisation interne uniquement</p> + <div *ngFor="let user of users" class="item user-item"> + <div class="info-container"> + <div class="image"> + <img src="/assets/ico/face.svg" alt="" /> + </div> + <div class="name"> + {{ user.name }} + </div> + </div> + <div class="downloads" *ngIf="areButtonsVisible"> + <app-button + variant="secondary" + size="small" + label="Format .csv" + iconName="download" + [disabled]="isDownloading" + (click)="downloadUserCsv(user)" + /> + </div> + </div> + </div> + </div> + </div> +</div> diff --git a/src/app/data/data.component.scss b/src/app/data/data.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..1b8d4bfa875157a84c7aaf4e634611d19f38d56c --- /dev/null +++ b/src/app/data/data.component.scss @@ -0,0 +1,216 @@ +@import 'color'; +@import 'layout'; +@import 'breakpoint'; +@import 'typography'; +@import 'z-index'; + +.data { + display: flex; + justify-content: center; + + .main-container { + padding: 32px 72px; + display: flex; + flex-direction: column; + gap: 24px; + width: $width-large-tablet; + max-width: 100%; + + @media #{$phone} { + padding: 16px; + } + + .title-container { + line-height: 120%; + padding: 0 0 8px 24px; + + .title { + @include font-bold-30; + color: $grey-1; + padding-bottom: 6px; + } + + .subtitle { + font-weight: 500; + margin-bottom: 1.5rem; + font-size: $font-size-medium; + } + + a { + color: $red; + text-decoration-line: underline; + } + } + + .filters-container { + display: flex; + flex-direction: column; + padding: 16px 24px; + background-color: $grey-9; + gap: 16px; + + p { + color: $grey-3; + } + + .filters { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 16px; + + @media #{$phone} { + gap: 24px; + } + + .row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + &.selects { + gap: 8px; + + @media #{$phone} { + gap: 16px; + } + } + + &.tags { + gap: 12px; + } + + @media #{$phone} { + app-collapsable-filter { + width: 100%; + } + } + } + } + } + + .content { + display: flex; + flex-direction: column; + padding: 0 24px; + gap: 24px; + + @media #{$tablet} { + padding: 0 4px; + } + + .result-title { + @include font-bold-20; + + .territory { + color: $red; + } + } + + .list { + display: flex; + flex-direction: column; + gap: 16px; + font-weight: 700; + padding-bottom: 8px; + + .list-header { + display: flex; + flex-direction: row; + gap: 16px; + width: 100%; + + .buttons-title { + @include font-regular-16; + color: $grey-4-5-1; + min-width: 316px; + + @media #{$tablet} { + min-width: auto; + } + } + } + + .list-title { + display: flex; + font-size: $font-size-smedium; + font-weight: 700; + flex-grow: 1; + } + + .list-subtitle { + font-size: $font-size-xsmall; + color: $grey-3; + } + + .item { + border-radius: 8px; + border: 1px solid $grey-7; + display: flex; + padding: 1rem; + + @media #{$tablet} { + flex-direction: column; + gap: 32px; + width: auto; + } + } + + .user-item { + width: calc(100% - 193px); + } + + .info-container { + display: flex; + flex-grow: 1; + + .image { + display: flex; + padding: 7px; + height: fit-content; + align-self: center; + gap: 10px; + border-radius: 46px; + background: $red-lighter; + } + + .name { + display: flex; + align-items: center; + margin-left: 1rem; + padding-right: 2px; + flex-grow: 1; + min-width: 400px; + + @media #{$tablet} { + min-width: auto; + } + } + } + + .downloads { + display: flex; + gap: 8px; + + @media #{$phone} { + flex-wrap: wrap; + max-width: 150px; + align-self: center; + } + + @media #{$tablet} { + justify-content: center; + gap: 16px; + } + } + + img { + width: 18px; + height: 18px; + filter: brightness(0) saturate(100%) invert(14%) sepia(100%) saturate(7495%) hue-rotate(358deg) + brightness(103%) contrast(121%); + } + } + } + } +} diff --git a/src/app/data/data.component.ts b/src/app/data/data.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..226379e6fcb3e83b9f3e79ce221eeea04d6248b7 --- /dev/null +++ b/src/app/data/data.component.ts @@ -0,0 +1,406 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, OnInit } from '@angular/core'; +import { DateTime } from 'luxon'; +import { lastValueFrom } from 'rxjs'; +import { DataType } from '../models/DataType.model'; +import { Structure } from '../models/structure.model'; +import { Week, WeekDayEnum } from '../models/week.model'; +import { AuthService } from '../services/auth.service'; +import { DataService } from '../services/data.service'; +import { NotificationService } from '../services/notification.service'; +import { Category } from '../structure-list/models/category.model'; +import { Module } from '../structure-list/models/module.model'; +import { SearchService } from '../structure-list/services/search.service'; +import { Utils } from '../utils/utils'; +import { territoryOptions } from './enums/territoryOptions.enum'; + +@Component({ + selector: 'app-data', + templateUrl: './data.component.html', + styleUrls: ['./data.component.scss'], +}) +export class DataComponent implements OnInit { + public structures = this.dataService.structures; + public users = this.dataService.users; + public categoriesCTMs: Category[]; + public categoriesInseeCodes: Category[]; + public categoriesTerritory: Category[]; + public checkedModulesFilter: Module[] = []; + public checkedModulesFilterLabel: string; + public checkedModulesCTMs: Module[] = []; + public checkedModulesInseeCodes: Module[] = []; + public checkedModulesTerritory: Module[] = []; + public territorySelectLabel: string = 'Sélectionnez un territoire'; + public areButtonsVisible: boolean = false; + public selectedOption: string = ''; + public isDownloading: boolean = false; + + private utils = new Utils(); + private categories: Category[]; + + constructor( + private authService: AuthService, + private dataService: DataService, + public searchService: SearchService, + private notificationService: NotificationService, + private http: HttpClient, + ) {} + + ngOnInit(): void { + this.searchService.getCategories().subscribe((categories) => { + this.categoriesTerritory = [ + { + id: 'Territory', + name: 'Territory', + theme: 'Territory', + modules: Object.keys(territoryOptions) + .filter((key) => !isNaN(Number(key))) + .map((key, index) => ({ + disabled: false, + name: territoryOptions[key], + id: 'choice' + (index + 1).toString(), + })), + }, + ]; + this.categories = categories; + this.categoriesCTMs = categories.filter((c) => c.id === 'ctm'); + }); + + // Get cities from data.grandlyon.com + // To change to have new city Oullins-Pierre-Bénite : + // .get('/fr/datapusher/ws/grandlyon/adr_voie_lieu.adrcomgl_2024/all.json', { headers: { skip: 'true' } }) + this.http + .get('/fr/datapusher/ws/grandlyon/adr_voie_lieu.adrcomgl/all.json', { headers: { skip: 'true' } }) + .subscribe((data: { values: any[] }) => { + this.categoriesInseeCodes = [ + { + id: 'inseeCode', + name: 'Commune', + theme: 'Commune', + modules: data.values + .flatMap((value) => { + if (value.nom === 'Lyon') { + // Replace city "Lyon" by the 9 districts of Lyon + return Array.from({ length: 9 }, (_, index) => ({ + disabled: false, + name: `Lyon ${index + 1}`, + id: `6938${index + 1}`, // Insee code for district of Lyon + })); + } + return [{ disabled: false, name: value.nom, id: value.insee }]; + }) + .sort((a, b) => a.name.localeCompare(b.name)), + }, + ]; + }); + + this.resetFilters(); + } + + public async downloadStructurePdf(dataType: DataType): Promise<void> { + this.isDownloading = true; + const fileName = this.getFileBasename(dataType) + '.pdf'; + + let urlPath = 'data/structures?data=' + dataType.slug; + if (this.selectedOption === 'choice2') { + const CTMs = this.categoriesCTMs[0].modules.map((ctm) => ctm.id).join(','); + urlPath += '&ctm=' + CTMs; + } else if (this.checkedModulesCTMs.length) { + const CTMs = this.checkedModulesCTMs.map((m) => m.id).join(','); + urlPath += '&ctm=' + CTMs; + } + if (this.checkedModulesInseeCodes.length) { + const inseeCodes = this.checkedModulesInseeCodes.map((m) => m.id).join(','); + urlPath += '&inseeCode=' + inseeCodes; + } + + const pdfBlob = await lastValueFrom(this.dataService.getPdf(urlPath, fileName)).catch(() => { + this.notificationService.showErrorPleaseRetry('Échec de la création du fichier pdf'); + return; + }); + if (!pdfBlob) { + this.isDownloading = false; + return; + } + + // Create a temporary URL for downloading + const blobUrl = window.URL.createObjectURL(pdfBlob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = fileName; + + // Start the download + link.click(); + this.isDownloading = false; + + // Clean up the temporary URL after the download + window.URL.revokeObjectURL(blobUrl); + } + + public async downloadStructureCsv(dataType: DataType): Promise<void> { + this.isDownloading = true; + const CTMs = this.getCTMs(); + const inseeCodes = this.checkedModulesInseeCodes.map((m) => m.id); + + // Get structures using specified filter for this DataType + const structures = await lastValueFrom(this.dataService.getStructures(dataType, CTMs, inseeCodes)).catch(() => { + this.notificationService.showErrorPleaseRetry('Échec de la création du fichier csv'); + return; + }); + if (!structures) { + this.isDownloading = false; + return; + } + + if (structures.length === 0) { + this.notificationService.showError( + 'Aucune structure ne correspond à vos paramètres', + 'Veuillez modifier votre recherche', + 10000, + ); + this.isDownloading = false; + return; + } + + // Sort for csv file + structures.sort( + (a, b) => a.address.commune.localeCompare(b.address.commune) || a.structureName.localeCompare(b.structureName), + ); + + // Filter fields in structures + const filteredStructures = structures.map((structure) => { + // Set category names in this.structure.categoriesDisplay for structure and all its personal offers + this.utils.setServiceCategoriesWithPersonalOffers(this.categories, structure); + + return { + Nom: structure.structureName, + Commune: structure.address.commune, + 'Aides aux démarches en ligne (gratuits)': structure.categoriesDisplay?.onlineProcedures + .map((acc) => acc.name) + .join(', '), + 'Accompagnements aux usages numériques': structure.categoriesDisplay?.baseSkills + .concat(structure.categoriesDisplay?.advancedSkills) + .map((skill) => skill.name) + .join(', '), + 'Gratuité des accompagnements': + structure.categoriesDisplay?.freeWorkShop?.length > 0 ? structure.categoriesDisplay.freeWorkShop[0].name : '', + Téléphone: structure.contactPhone || '', + Email: structure.contactMail || '', + Description: structure.description || '', + + Horaires: this.formatOpeningHoursForCSV(structure.hours), + + labels: structure.categoriesDisplay?.labelsQualifications.map((label) => label.name).join(', '), + "Modalité d'accès": structure.categoriesDisplay?.accessModality.map((access) => access.name).join(', '), + 'Date de mise à jour': DateTime.fromISO(structure.updatedAt).toFormat('yyyy-MM-dd HH:mm'), + }; + }); + + // Convert and download to csv + this.utils.convertAndDownloadCsv(filteredStructures, this.getFileBasename(dataType) + '.csv'); + + this.isDownloading = false; + } + + public async downloadUserCsv(dataType: DataType): Promise<void> { + // Check if the user is logged in + if (!this.authService.isLoggedIn()) { + this.notificationService.showError( + 'Utilisation interne uniquement', + 'Pour accéder aux membres, veuillez vous connecter', + 10000, + ); + return; + } + + this.isDownloading = true; + const CTMs = this.getCTMs(); + const inseeCodes = this.checkedModulesInseeCodes.map((m) => m.id); + + // Get users using specified filter for this DataType + const users = await this.dataService.getUsers(dataType, CTMs, inseeCodes).catch(() => { + this.notificationService.showErrorPleaseRetry('Échec de la création du fichier csv'); + return; + }); + if (!users) { + this.isDownloading = false; + return; + } + + if (users.length === 0) { + this.notificationService.showError( + 'Aucun membre ne correspond à vos paramètres', + 'Veuillez modifier votre recherche', + 10000, + ); + this.isDownloading = false; + return; + } + + // Sort for csv file + users.sort((a, b) => a.surname.localeCompare(b.surname)); + + // Filter fields in users + const filteredUsers = users.map((user) => { + // Make a temporary partial structure so we can use setServiceCategories for translations + const tempObj: Partial<Structure> = { categoriesDisplay: {} }; + + // Translate offers + const translatedOffers = + user.personalOffersData?.map((offer) => + this.utils.setServiceCategories(this.categories, { ...offer, categoriesDisplay: {} }), + ) || []; + translatedOffers.forEach((offer) => { + Object.entries(offer.categoriesDisplay).forEach(([categoryId, modules]) => { + const category = this.categories.find((cat) => cat.id === categoryId); + const categoryName = category?.name || categoryId; + if (!tempObj.categoriesDisplay[categoryName]) { + tempObj.categoriesDisplay[categoryName] = []; + } + if (Array.isArray(modules)) { + tempObj.categoriesDisplay[categoryName] = [ + ...new Set([...tempObj.categoriesDisplay[categoryName], ...modules]), + ]; + } + }); + }); + + // Construct the offer string + const offre = Object.entries(tempObj.categoriesDisplay) + .filter(([, modules]) => modules.length > 0) + .map(([categoryName, modules]) => `${categoryName}: ${modules.map((m) => m.name).join(', ')}`) + .join(' / '); + + // Construct the territories string + const territoires = [ + ...new Set( + user.structuresData?.flatMap((s) => { + const foundCategory = this.categoriesCTMs[0].modules.find((c) => c.id === s.categories.ctm[0]); + return foundCategory ? foundCategory.name : 'Nouveau Rhône'; + }), + ), + ].join(', '); + + return { + Nom: `${user.name} ${user.surname.toUpperCase()}`, + Fonction: user.job?.name, + Téléphone: user.phone, + Email: user.email, + 'Structure employeuse': user.employer?.name, + 'Commune(s)': [...new Set(user.structuresData?.map((s) => s.address.commune) || [])].join(', '), + 'Territoire(s)': territoires, + "Structures d'intervention": user.structuresData?.map((s) => s.structureName).join(', '), + Offre: offre, + }; + }); + + // Convert and download to CSV + this.utils.convertAndDownloadCsv(filteredUsers, this.getFileBasename(dataType) + '.csv'); + + this.isDownloading = false; + } + + private getCTMs(): string[] { + // If "Toute la métropole" choice, filter with all the CTMs + if (this.selectedOption === 'choice2') { + return this.categoriesCTMs[0].modules.map((ctm) => ctm.id); + } else { + // Filter with checked CTMs + return this.checkedModulesCTMs.map((m) => m.id); + } + } + + private getFileBasename(dataType: DataType): string { + const formattedDate = DateTime.now().toFormat('yyyy-MM-dd'); + return `resin-${dataType.slug}-${formattedDate}`; + } + + private formatOpeningHoursForCSV(schedule: Week): string { + const formattedSchedule = []; + + for (const { key, value } of schedule) { + const translatedDay = WeekDayEnum[key]; + if (value.open && value.time.length > 0) { + const times = value.time.map((t) => `${t.formatOpeningDate()} - ${t.formatClosingDate()}`).join(' / '); + formattedSchedule.push(`${translatedDay}: ${times}`); + } else { + formattedSchedule.push(`${translatedDay}: fermé`); + } + } + + return formattedSchedule.join(', '); + } + + public setFilters(checkedModules: Module[]): void { + this.resetState(); + // Early return if no modules are checked + if (checkedModules.length === 0) { + this.resetFilters(); + return; + } + // Filter and set checked modules + this.checkedModulesFilter = checkedModules.filter((module: Module) => !['choice3', 'choice4'].includes(module.id)); + // Set label for checked modules + this.getResultTitle(); + // Determine module type and set corresponding property + const firstModule = checkedModules[0]; + switch (firstModule.name) { + case 'ctm': + this.checkedModulesCTMs = checkedModules; + this.selectedOption = 'choice3'; + break; + case 'inseeCode': + this.checkedModulesInseeCodes = checkedModules; + this.selectedOption = 'choice4'; + break; + default: + if (firstModule.id.startsWith('choice')) { + this.selectedOption = firstModule.id; + this.checkedModulesTerritory = checkedModules; + } + } + // Set territory select label + const labelMap = { + choice3: 'Territoires métropolitains', + choice4: 'Communes', + }; + this.territorySelectLabel = labelMap[this.selectedOption] || this.territorySelectLabel; + // Set button visibility + this.areButtonsVisible = this.checkedModulesFilter.length > 0; + } + + private getResultTitle(): string { + this.checkedModulesFilterLabel = this.checkedModulesFilter.map((m) => m.displayText).join(', '); + return this.checkedModulesFilterLabel; + } + + private resetState(): void { + this.areButtonsVisible = false; + this.checkedModulesCTMs = []; + this.checkedModulesInseeCodes = []; + this.checkedModulesFilter = []; + this.checkedModulesFilterLabel = ''; + this.selectedOption = ''; + } + + public removeFilter(module: Module): void { + const index: number = this.checkedModulesFilter.findIndex((m: Module) => m.id === module.id); + if (index !== -1) { + // update global list of checked filters + this.checkedModulesFilter = this.checkedModulesFilter.filter((m: Module) => m.id !== module.id); + // update each select + this.checkedModulesCTMs = this.checkedModulesFilter.filter((module) => module.name === 'ctm'); + this.checkedModulesInseeCodes = this.checkedModulesFilter.filter((module) => module.name === 'inseeCode'); + this.getResultTitle(); + this.checkedModulesFilterLabel = this.checkedModulesFilter.length > 0 ? this.checkedModulesFilterLabel : ''; + this.areButtonsVisible = this.checkedModulesFilter.length > 0; + if (this.checkedModulesFilter.length === 0) this.resetFilters(); + } + } + + public resetFilters(): void { + this.checkedModulesTerritory = []; + this.territorySelectLabel = 'Sélectionnez un territoire'; + this.resetState(); + } +} diff --git a/src/app/data/enums/territoryOptions.enum.ts b/src/app/data/enums/territoryOptions.enum.ts new file mode 100644 index 0000000000000000000000000000000000000000..db2f2a3affb3b3a26eaaf4a421e0ff6e780129ea --- /dev/null +++ b/src/app/data/enums/territoryOptions.enum.ts @@ -0,0 +1,6 @@ +export enum territoryOptions { + 'Toutes les données de Rés’in', + 'Toute la métropole', + 'Filtrer par territoires métropolitains', + 'Filtrer par communes', +} diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index 1ac4e16252da205b55775dc09666a9fb1da19bcd..15301ff2b67c56f83ae212300e1740378598361c 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,4 +1,4 @@ -<footer class="footer"> +<footer class="footer hide-on-print"> <div class="links"> <a class="clickable text-align-center" routerLink="/mentions-legales" i18n>Mentions légales</a> <a class="clickable text-align-center" routerLink="/newsletter" i18n>Newsletter</a> diff --git a/src/app/header/header.component.html b/src/app/header/header.component.html index 523e6611f942476bb61eb4e3d35c98e09ae4576f..50d22e885115733bfc272762735afb7c9aea01bd 100644 --- a/src/app/header/header.component.html +++ b/src/app/header/header.component.html @@ -80,6 +80,16 @@ > Annuaire </a> + <a + routerLink="/data" + role="menuitem" + title="Rés'in Data" + [routerLinkActive]="'active'" + i18n + [attr.aria-current]="isActive('/data')" + > + Rés'in Data + </a> <a *ngIf="isAdmin" routerLink="/admin" @@ -162,6 +172,17 @@ (click)="closeMenu()" >Annuaire</a > + <a + routerLink="/data" + role="menuitem" + title="Rés'in Data" + [routerLinkActive]="'active'" + i18n + [attr.aria-current]="isActive('/data')" + (click)="closeMenu()" + > + Rés'in Data + </a> <a routerLink="/page/qui-sommes-nous" role="menuitem" diff --git a/src/app/models/DataType.model.ts b/src/app/models/DataType.model.ts new file mode 100644 index 0000000000000000000000000000000000000000..e56dd71e16085dae31a06df35595433406c1b4ac --- /dev/null +++ b/src/app/models/DataType.model.ts @@ -0,0 +1,4 @@ +export interface DataType { + name: string; + slug: string; +} diff --git a/src/app/services/data.service.ts b/src/app/services/data.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1e9c516ea269102b61fce532b91ffdeb2c9ab27 --- /dev/null +++ b/src/app/services/data.service.ts @@ -0,0 +1,123 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { lastValueFrom, Observable } from 'rxjs'; +import { DataType } from '../models/DataType.model'; +import { Structure } from '../models/structure.model'; +import { Filter } from '../structure-list/models/filter.model'; +import { SearchService } from '../structure-list/services/search.service'; +import { StructureService } from './structure.service'; + +@Injectable({ + providedIn: 'root', +}) +export class DataService { + public structures = [ + { + name: 'Tous les lieux présents sur la cartographie Rés’in', + slug: 'structures', + }, + { + name: 'Lieux proposant un accompagnement gratuit au numérique', + slug: 'structures-accompagnement-gratuit', + }, + { + name: "Lieux labellisés 'Maison France Service'", + slug: 'structures-france-service', + }, + { + name: "Lieux accueillant des Conseiller·es Numériques (dispositif de l'État)", + slug: 'structures-conseillers-numeriques', + }, + { + name: 'Lieux proposant du matériel numérique en accès libre', + slug: 'structures-materiel', + }, + ]; + + public users = [ + { + name: 'Liste des accompagnants.es numériques', + slug: 'membres-accompagnants-numeriques', + }, + { + name: 'Liste des Conseiller.es Numériques (dispositif de l’État)', + slug: 'membres-conseillers-numeriques', + }, + ]; + + constructor( + private http: HttpClient, + private structureService: StructureService, + private searchService: SearchService, + ) {} + + /** + * Retrieve pdf file + */ + public getPdf(urlPath: string, fileName: string): Observable<Blob> { + const body = { + urlPath: urlPath, + fileName: fileName, + }; + + return this.http.post('api/pdf', body, { + responseType: 'blob', + }); + } + + /** + * Get structures using specified filter for this DataType + */ + public getStructures(dataType: DataType, CTMs?: string[], inseeCodes?: string[]): Observable<Structure[]> { + const filters: Filter[] = []; + switch (dataType.slug) { + // Structures + case 'structures-accompagnement-gratuit': + filters.push(new Filter('freeWorkShop', 'yes', null, true)); + filters.push(new Filter('freeWorkShop', 'underCondition', null, true)); + break; + case 'structures-france-service': + filters.push(new Filter('labelsQualifications', 'maisonFranceService', null)); + break; + case 'structures-conseillers-numeriques': + filters.push(new Filter('labelsQualifications', 'conseillerNumFranceServices', null)); + break; + case 'structures-materiel': + filters.push(new Filter('selfServiceMaterial', 'computer', null, true)); + filters.push(new Filter('selfServiceMaterial', 'printer', null, true)); + filters.push(new Filter('selfServiceMaterial', 'scanner', null, true)); + break; + } + + // CTMs filters + CTMs?.forEach((ctm) => { + filters.push(new Filter('ctm', ctm, undefined, true)); + }); + + return this.structureService.getStructures(filters, undefined, undefined, undefined, inseeCodes); + } + + public async getUsers(dataType: DataType, ctms?: string[], inseeCodes?: string[]): Promise<any> { + let hasPersonalOffer = false; + let job = []; + switch (dataType.slug) { + case 'membres-accompagnants-numeriques': + hasPersonalOffer = true; + break; + case 'membres-conseillers-numeriques': + job = ['Conseiller numérique', 'Conseillère numérique']; + break; + } + + return lastValueFrom( + this.http.get('/api/users/filteredUsers', { + params: { + hasPersonalOffer, + job: job.join(','), + ctms: ctms ? ctms.join(',') : '', + inseeCodes: inseeCodes ? inseeCodes.join(',') : '', + }, + }), + ); + } +} diff --git a/src/app/services/structure.service.ts b/src/app/services/structure.service.ts index acd3e5a6dc211fe33b6363915ab5ff7f6a7ac2da..52bb637ebe21c21cea2f2018c9592519e799dee8 100644 --- a/src/app/services/structure.service.ts +++ b/src/app/services/structure.service.ts @@ -71,6 +71,7 @@ export class StructureService { searchUrl = 'search', onlyOffersWithAppointment?: boolean, limit?: number, + inseeCodes?: string[], ): Observable<Structure[]> { let requestUrl = `${this.baseUrl}/${searchUrl}`; let requestFilters = null; @@ -92,6 +93,10 @@ export class StructureService { limit, }; } + + // Add optional inseeCodes filter + requestFilters = { ...requestFilters, inseeCodes }; + return this.http .post(requestUrl, requestFilters) .pipe(map((data: any[]) => data.map((item) => new Structure(item)))); diff --git a/src/app/shared/components/button/button.component.scss b/src/app/shared/components/button/button.component.scss index ce30e9e2374f7514843ea3ed34ae303a4c1c65e4..91f0fc37490b463e57499086a13b5448525d5c17 100644 --- a/src/app/shared/components/button/button.component.scss +++ b/src/app/shared/components/button/button.component.scss @@ -176,4 +176,8 @@ button { background-color: $grey-8; } } + + &:disabled app-svg-icon { + filter: opacity(0.5); + } } diff --git a/src/app/shared/components/collapsable-filter/collapsable-filter.component.html b/src/app/shared/components/collapsable-filter/collapsable-filter.component.html index 54ea9ea78497edbef86d0541e3884a70923c8660..2d40f13b5030b3ab1bbdaa0965e4a3fdfc8033e7 100644 --- a/src/app/shared/components/collapsable-filter/collapsable-filter.component.html +++ b/src/app/shared/components/collapsable-filter/collapsable-filter.component.html @@ -6,7 +6,7 @@ [attr.aria-expanded]="expanded" [ngClass]="{ expanded: expanded, - active: checkedModules.length > 0, + active: checkedModules.length > 0 }" (click)="toggleExpanded()" > diff --git a/src/app/shared/components/collapsable-filter/collapsable-filter.component.scss b/src/app/shared/components/collapsable-filter/collapsable-filter.component.scss index abdd8287c85c4c7f24cd5c0fad3ac58507a7dd08..d8972100f65f3b3853edb2e826f965d7181ad058 100644 --- a/src/app/shared/components/collapsable-filter/collapsable-filter.component.scss +++ b/src/app/shared/components/collapsable-filter/collapsable-filter.component.scss @@ -48,3 +48,10 @@ button { margin-top: 16px; z-index: $modal-z-index; } + +@media #{$phone} { + button, + button > span { + width: 100%; + } +} diff --git a/src/app/structure-list/components/more-filters/more-filters.component.html b/src/app/structure-list/components/more-filters/more-filters.component.html index 11da1ab4e13f89e7f194dec42be00655b3fc12fd..46ebcb68609b1b454a37eb54872049f7d0379530 100644 --- a/src/app/structure-list/components/more-filters/more-filters.component.html +++ b/src/app/structure-list/components/more-filters/more-filters.component.html @@ -1,4 +1,8 @@ -<div cdkTrapFocus [cdkTrapFocusAutoCapture]="true" [ngClass]="['filterModal', isModal ? 'moreFilters' : '']"> +<div + cdkTrapFocus + [cdkTrapFocusAutoCapture]="true" + [ngClass]="['filterModal', isModal || isMobile ? 'moreFilters' : '', isRadio ? 'isRadio' : '']" +> <div class="filterModalContainer" (appClickOutside)="closeModal()"> <!-- Header for "Other filters" modal --> <div *ngIf="isModal" class="moreFiltersHeader"> @@ -73,9 +77,15 @@ [variant]="'secondary'" [label]="'Effacer'" [ariaLabel]="'Effacer et fermer'" + [wide]="isMobile" (action)="clearFilters()" /> - <app-button [variant]="'primary'" [label]="'Appliquer'" (action)="emitModules(pendingCheckedModules)" /> + <app-button + [variant]="'primary'" + [label]="'Appliquer'" + [wide]="isMobile" + (action)="emitModules(pendingCheckedModules)" + /> </div> </div> </div> diff --git a/src/app/structure-list/components/more-filters/more-filters.component.scss b/src/app/structure-list/components/more-filters/more-filters.component.scss index 9aacad045c5b0ed9bc095a5594f95d3cf38ab99b..5fc0863bd0a65de8f6d6881cb405b8d613170bb1 100644 --- a/src/app/structure-list/components/more-filters/more-filters.component.scss +++ b/src/app/structure-list/components/more-filters/more-filters.component.scss @@ -38,6 +38,13 @@ } } + @media #{$phone} { + background: rgba(0, 0, 0, 0.25); + .filterModalContainer { + max-width: 90vw; + } + } + .modalContent { display: flex; flex-direction: column; @@ -74,5 +81,21 @@ justify-content: center; gap: 12px; padding-block: 16px; + flex-flow: wrap; + } +} +.isRadio { + .modalContent { + gap: unset; + } + app-radio { + padding: 12px 8px; + border-bottom: 1px solid $grey-7; + &:hover { + background-color: $grey-7; + } + ::ng-deep label { + font-weight: 400; + } } } diff --git a/src/app/structure-list/components/more-filters/more-filters.component.ts b/src/app/structure-list/components/more-filters/more-filters.component.ts index 2d69252d223960e4a65f7c6ac40b277c08caa2bb..9b6f6f1f285e5177d6065bfeb0ddd9d511ced3ca 100644 --- a/src/app/structure-list/components/more-filters/more-filters.component.ts +++ b/src/app/structure-list/components/more-filters/more-filters.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, HostListener, Input, OnInit, Output } from '@angular/core'; import { CategoryEnum } from '../../../shared/enum/category.enum'; import { Category } from '../../models/category.model'; import { Module } from '../../models/module.model'; @@ -23,12 +23,14 @@ export class MoreFiltersComponent implements OnInit { @Output() selectEvent = new EventEmitter(); @Output() closeEvent = new EventEmitter(); public categoryEnum = CategoryEnum; + public isMobile: boolean = false; /** Checked but not yet validated modules */ public pendingCheckedModules: Module[] = []; ngOnInit(): void { // Manage checkbox this.pendingCheckedModules = this.checkedModules.slice(); + this.checkMobile(); } // Management of the checkbox event (Check / Uncheck) @@ -103,4 +105,13 @@ export class MoreFiltersComponent implements OnInit { return 'halfChecked'; } } + + // Checks if size of screen is less than 576px to display the modal with wide buttons instead of dropdown menu + @HostListener('window:resize', ['$event']) + onResize(event) { + this.checkMobile(); + } + checkMobile() { + this.isMobile = window.innerWidth <= 576; + } } diff --git a/src/app/structure-list/components/structure-details/structure-details.component.ts b/src/app/structure-list/components/structure-details/structure-details.component.ts index aaa38aa348027531f01d44b0e25db1228a25578e..9cd69cf50d9c6beaf06e78e260eaa4f5389c883d 100644 --- a/src/app/structure-list/components/structure-details/structure-details.component.ts +++ b/src/app/structure-list/components/structure-details/structure-details.component.ts @@ -1,7 +1,6 @@ import { animate, style, transition, trigger } from '@angular/animations'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import * as _ from 'lodash'; import { Owner } from '../../../models/owner.model'; import { Structure } from '../../../models/structure.model'; import { User } from '../../../models/user.model'; @@ -105,7 +104,7 @@ export class StructureDetailsComponent implements OnInit { this.searchService.getCategories().subscribe((categories) => { this.categories = categories; - this.setServiceCategories(); + this.utils.setServiceCategoriesWithPersonalOffers(this.categories, this.structure); this.isLoading = false; setTimeout(() => { this.closeButtonElement.nativeElement.children[0].focus(); @@ -224,33 +223,6 @@ export class StructureDetailsComponent implements OnInit { } } - /** - * Map categories ids to their real names - */ - public setServiceCategories(): void { - // Set category names in this.structure.categoriesDisplay - this.utils.setServiceCategories(this.categories, this.structure); - - // Merge the structure offer with the personal offers of all members of the structure - this.structure.personalOffers.forEach((personalOffer) => { - const personalOfferDisplay = this.utils.setServiceCategories(this.categories, personalOffer); - - // use lodash _.union fonction to concat array without duplicates - this.structure.categoriesDisplay.onlineProcedures = _.union( - this.structure.categoriesDisplay.onlineProcedures, - personalOfferDisplay.categoriesDisplay.onlineProcedures, - ); - this.structure.categoriesDisplay.baseSkills = _.union( - this.structure.categoriesDisplay.baseSkills, - personalOfferDisplay.categoriesDisplay.baseSkills, - ); - this.structure.categoriesDisplay.advancedSkills = _.union( - this.structure.categoriesDisplay.advancedSkills, - personalOfferDisplay.categoriesDisplay.advancedSkills, - ); - }); - } - public hasBaseSkills(): boolean { return this.structure.categoriesDisplay.baseSkills?.length > 0; } diff --git a/src/app/structure-list/components/structure-list-data/structure-list-data.component.html b/src/app/structure-list/components/structure-list-data/structure-list-data.component.html new file mode 100644 index 0000000000000000000000000000000000000000..30eebb80c4378405edfa6b76aa3f5c928ffe7a3b --- /dev/null +++ b/src/app/structure-list/components/structure-list-data/structure-list-data.component.html @@ -0,0 +1,21 @@ +<div class="contents"> + <app-print-header /> + + <div class="infos"> + <div class="inline"> + <app-svg-icon [iconClass]="'icon-20'" [folder]="'tags'" [icon]="'notification'" /> + <h2>Besoin d'aide avec le numérique ?</h2> + </div> + <p> + Pour utiliser votre ordinateur ou smartphone, aller sur internet, réaliser une démarche...<br /> + Vous pouvez trouver de l’aide ou du matériel dans les lieux suivants.<br /> + Si possible, appelez-les avant d’y aller pour connaître les horaires et les aides proposées. + </p> + <div class="inline"> + <h2>{{ dataType.name }}</h2> + </div> + + <app-print-structures-grid *ngIf="structures" [structures]="structures" /> + <p *ngIf="structures?.length === 0">Aucune structure ne correspond à vos paramètres</p> + </div> +</div> diff --git a/src/app/structure-list/components/structure-list-data/structure-list-data.component.scss b/src/app/structure-list/components/structure-list-data/structure-list-data.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..12554a0b620352f26f35b0de57f7b4c1f1ef5236 --- /dev/null +++ b/src/app/structure-list/components/structure-list-data/structure-list-data.component.scss @@ -0,0 +1,22 @@ +@import 'color'; +@import 'typography'; + +.contents { + display: flex; + flex-direction: column; + gap: 24px; + + .infos { + display: flex; + flex-direction: column; + gap: 16px; + + h2 { + @include font-bold-18; + } + + p { + @include font-regular-14; + } + } +} diff --git a/src/app/structure-list/components/structure-list-data/structure-list-data.component.ts b/src/app/structure-list/components/structure-list-data/structure-list-data.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..446a3726890a3395956822155c15a7cddf103aa0 --- /dev/null +++ b/src/app/structure-list/components/structure-list-data/structure-list-data.component.ts @@ -0,0 +1,34 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { lastValueFrom } from 'rxjs'; +import { DataType } from '../../../models/DataType.model'; +import { Structure } from '../../../models/structure.model'; +import { DataService } from '../../../services/data.service'; + +@Component({ + selector: 'app-structure-list-data', + templateUrl: './structure-list-data.component.html', + styleUrls: ['./structure-list-data.component.scss'], +}) +export class StructureListDataComponent implements OnInit { + public dataType: DataType; + public structures: Structure[]; + + constructor( + private route: ActivatedRoute, + private dataService: DataService, + ) {} + + async ngOnInit(): Promise<void> { + this.route.queryParams.subscribe(async (params) => { + const slug = params.data; + const CTMs = params.ctm?.split(','); + const inseeCodes = params.inseeCode?.split(','); + + this.dataType = this.dataService.structures.find((dataType) => dataType.slug === slug); + + // Get structures using specified filter for this DataType + this.structures = await lastValueFrom(this.dataService.getStructures(this.dataType, CTMs, inseeCodes)); + }); + } +} diff --git a/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts b/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts index ad391b457fd8a279d14185655095fbdb59cb3d4a..5791d887b7a1119dce25f501f4b8f164f6e69141 100644 --- a/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts +++ b/src/app/structure-list/components/structure-list-search/structure-list-search.component.ts @@ -129,7 +129,7 @@ export class StructureListSearchComponent implements OnInit { this.applyFilter(); } - // Check if some modules is checked on filter and store number of modules checked + // Check if some modules is checked on filter and store modules checked public setCheckFiltersOnModules(checkedModules: Module[]): void { this.trainingChecked = checkedModules.filter( (module) => diff --git a/src/app/utils/utils.ts b/src/app/utils/utils.ts index 36eedd9dcdb88f4e03ed1f6561021cae0064c395..80b6b4d1b8aadd38f7a669e31e2ab12305265787 100644 --- a/src/app/utils/utils.ts +++ b/src/app/utils/utils.ts @@ -1,5 +1,6 @@ import { ElementRef, Injectable } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; +import * as _ from 'lodash'; import { Owner } from '../models/owner.model'; import { Structure } from '../models/structure.model'; import { User } from '../models/user.model'; @@ -115,6 +116,9 @@ export class Utils { return modules; } + /** + * Map categories ids to their real names + */ public setServiceCategories(categories: Category[], structure: Structure | PersonalOffer): Structure | PersonalOffer { categories.forEach((category) => { const structureModuleIds = structure.categories[category.id]; @@ -135,6 +139,33 @@ export class Utils { return structure; } + /** + * Map categories ids to their real names for structure and all its personal offers + */ + public setServiceCategoriesWithPersonalOffers(categories: Category[], structure: Structure): void { + // Set category names in this.structure.categoriesDisplay + this.setServiceCategories(categories, structure); + + // Merge the structure offer with the personal offers of all members of the structure + structure.personalOffers.forEach((personalOffer) => { + const personalOfferDisplay = this.setServiceCategories(categories, personalOffer); + + // use lodash _.union fonction to concat array without duplicates + structure.categoriesDisplay.onlineProcedures = _.union( + structure.categoriesDisplay.onlineProcedures, + personalOfferDisplay.categoriesDisplay.onlineProcedures, + ); + structure.categoriesDisplay.baseSkills = _.union( + structure.categoriesDisplay.baseSkills, + personalOfferDisplay.categoriesDisplay.baseSkills, + ); + structure.categoriesDisplay.advancedSkills = _.union( + structure.categoriesDisplay.advancedSkills, + personalOfferDisplay.categoriesDisplay.advancedSkills, + ); + }); + } + /** * Check if trainings are selected in order to ask for pricing or reset field "autres" */ @@ -163,4 +194,49 @@ export class Utils { public isNullOrEmpty(value: string): boolean { return this.nullifyEmpty(value) === null; } + + /** + * Convert and download to csv + */ + public convertAndDownloadCsv(data: any[], filename: string): void { + const csv = this.convertToCSV(data); + this.downloadCSV(csv, filename); + } + + private convertToCSV(data: any[]): string { + const headers = Object.keys(data[0]).join(','); // CSV headers + + const rows = data.map((row) => { + return Object.values(row) + .map((value) => { + // Convert the value to a string + let strValue = String(value); + + // If the value contains double quotes, replace them with two double quotes + if (strValue.includes('"')) { + strValue = strValue.replace(/"/g, '""'); + } + + // If the value contains commas, newlines, or apostrophes, enclose it in double quotes + if (strValue.includes(',') || strValue.includes('\n') || strValue.includes("'")) { + strValue = `"${strValue}"`; + } + + return strValue; + }) + .join(','); // Join fields with commas + }); + + return [headers, ...rows].join('\n'); // Return the headers and data rows + } + + private downloadCSV(csv: string, filename: string): void { + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.setAttribute('href', url); + a.setAttribute('download', filename); + a.click(); + window.URL.revokeObjectURL(url); // Clean up URL + } } diff --git a/src/assets/ico/face.svg b/src/assets/ico/face.svg new file mode 100644 index 0000000000000000000000000000000000000000..26e14b5c1ea327bbaaaef51ad6ad2193a51108f1 --- /dev/null +++ b/src/assets/ico/face.svg @@ -0,0 +1,5 @@ +<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g id="face"> +<path id="Vector" d="M2.83425 7.89817L2.57104 8.01528L2.54035 8.30173C2.51651 8.52415 2.5 8.76009 2.5 9C2.5 12.5836 5.41636 15.5 9 15.5C12.5836 15.5 15.5 12.5836 15.5 9C15.5 8.35675 15.4023 7.73902 15.232 7.16319L15.0978 6.70971L14.6376 6.81838C14.1351 6.93702 13.6112 7 13.065 7C10.7068 7 8.62115 5.8321 7.35326 4.03885L6.82774 3.29556L6.48238 4.13781C5.79607 5.81156 4.48626 7.16317 2.83425 7.89817ZM6.3125 9.75C6.3125 9.50864 6.50864 9.3125 6.75 9.3125C6.99136 9.3125 7.1875 9.50864 7.1875 9.75C7.1875 9.99136 6.99136 10.1875 6.75 10.1875C6.50864 10.1875 6.3125 9.99136 6.3125 9.75ZM10.8125 9.75C10.8125 9.50864 11.0086 9.3125 11.25 9.3125C11.4914 9.3125 11.6875 9.50864 11.6875 9.75C11.6875 9.99136 11.4914 10.1875 11.25 10.1875C11.0086 10.1875 10.8125 9.99136 10.8125 9.75ZM2 9C2 5.13614 5.13614 2 9 2C12.8639 2 16 5.13614 16 9C16 12.8639 12.8639 16 9 16C5.13614 16 2 12.8639 2 9Z" fill="#DA3635" stroke="#DA3635"/> +</g> +</svg> diff --git a/src/assets/ico/sprite.svg b/src/assets/ico/sprite.svg index 15fe9139d750648d70cdb86dc9ccd4af8489fab2..5612c0cac15e56c4679ff7bb0aa5b4fbda5f900c 100644 --- a/src/assets/ico/sprite.svg +++ b/src/assets/ico/sprite.svg @@ -395,4 +395,12 @@ d="M15.8327 6.66667H4.16602C2.78268 6.66667 1.66602 7.78333 1.66602 9.16667V12.5C1.66602 13.4167 2.41602 14.1667 3.33268 14.1667H4.99935V15.8333C4.99935 16.75 5.74935 17.5 6.66602 17.5H13.3327C14.2493 17.5 14.9993 16.75 14.9993 15.8333V14.1667H16.666C17.5827 14.1667 18.3327 13.4167 18.3327 12.5V9.16667C18.3327 7.78333 17.216 6.66667 15.8327 6.66667ZM12.4993 15.8333H7.49935C7.04102 15.8333 6.66602 15.4583 6.66602 15V11.6667H13.3327V15C13.3327 15.4583 12.9577 15.8333 12.4993 15.8333ZM15.8327 10C15.3743 10 14.9993 9.625 14.9993 9.16667C14.9993 8.70833 15.3743 8.33333 15.8327 8.33333C16.291 8.33333 16.666 8.70833 16.666 9.16667C16.666 9.625 16.291 10 15.8327 10ZM14.166 2.5H5.83268C5.37435 2.5 4.99935 2.875 4.99935 3.33333V5C4.99935 5.45833 5.37435 5.83333 5.83268 5.83333H14.166C14.6243 5.83333 14.9993 5.45833 14.9993 5V3.33333C14.9993 2.875 14.6243 2.5 14.166 2.5Z" fill="white" /> </symbol> + + <symbol id="download" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g id="get-app"> + <path id="Vector" + d="M14.325 7.5H13V3.33333C13 2.875 12.625 2.5 12.1667 2.5H8.83332C8.37499 2.5 7.99999 2.875 7.99999 3.33333V7.5H6.67499C5.93332 7.5 5.55832 8.4 6.08332 8.925L9.90832 12.75C10.2333 13.075 10.7583 13.075 11.0833 12.75L14.9083 8.925C15.4333 8.4 15.0667 7.5 14.325 7.5ZM4.66666 15.8333C4.66666 16.2917 5.04166 16.6667 5.49999 16.6667H15.5C15.9583 16.6667 16.3333 16.2917 16.3333 15.8333C16.3333 15.375 15.9583 15 15.5 15H5.49999C5.04166 15 4.66666 15.375 4.66666 15.8333Z" + fill="#333333" /> + </g> + </symbol> </svg> \ No newline at end of file