diff --git a/app/controllers/ApplicationController.scala b/app/controllers/ApplicationController.scala index 30ab7c278..ea97ff6c4 100644 --- a/app/controllers/ApplicationController.scala +++ b/app/controllers/ApplicationController.scala @@ -21,10 +21,12 @@ import play.api.cache.AsyncCacheApi import play.api.data.Forms._ import play.api.data._ import play.api.data.validation.Constraints._ +import play.api.libs.json.Json import play.api.libs.ws.WSClient import play.api.mvc._ import play.twirl.api.Html import serializers.Keys +import serializers.ApiModel.{ApplicationMetadata, ApplicationMetadataResult} import services._ import views.stats.StatsData @@ -419,17 +421,28 @@ case class ApplicationController @Inject() ( applicationService.allForAreas(List(area.id), numOfMonthsDisplayed.some) case (false, None) if user.groupAdmin => val userIds = userService.byGroupIds(user.groupIds, includeDisabled = true).map(_.id) - applicationService.allForUserIds(userIds) + applicationService.allForUserIds(userIds, numOfMonthsDisplayed.some) case (false, Some(area)) if user.groupAdmin => - val userGroupIds = - userGroupService.byIds(user.groupIds).filter(_.areaIds.contains[UUID](area.id)).map(_.id) - val userIds = userService.byGroupIds(userGroupIds, includeDisabled = true).map(_.id) - applicationService.allForUserIds(userIds) + val userIds = userService.byGroupIds(user.groupIds, includeDisabled = true).map(_.id) + applicationService + .allForUserIds(userIds, numOfMonthsDisplayed.some) + .map(_.filter(application => application.area === area.id)) case _ => - Future(Nil) + Future.successful(Nil) } - def all(areaId: UUID): Action[AnyContent] = + private def extractApplicationsAdminQuery(implicit + request: RequestWithUserData[_] + ): (Option[Area], Int) = { + val areaOpt = areaInQueryString.filterNot(_.id === Area.allArea.id) + val numOfMonthsDisplayed: Int = request + .getQueryString(Keys.QueryParam.numOfMonthsDisplayed) + .flatMap(s => Try(s.toInt).toOption) + .getOrElse(3) + (areaOpt, numOfMonthsDisplayed) + } + + def applicationsAdmin: Action[AnyContent] = loginAction.async { implicit request => (request.currentUser.admin, request.currentUser.groupAdmin) match { case (false, false) => @@ -443,29 +456,60 @@ case class ApplicationController @Inject() ( ) ) case _ => - val numOfMonthsDisplayed: Int = request - .getQueryString(Keys.QueryParam.numOfMonthsDisplayed) - .flatMap(s => Try(s.toInt).toOption) - .getOrElse(3) - val area = if (areaId === Area.allArea.id) None else Area.fromId(areaId) - allApplicationVisibleByUserAdmin(request.currentUser, area, numOfMonthsDisplayed).map { - unfilteredApplications => - val filteredApplications = - request.getQueryString(Keys.QueryParam.filterIsOpen) match { - case Some(_) => unfilteredApplications.filterNot(_.closed) - case None => unfilteredApplications - } + val (areaOpt, numOfMonthsDisplayed) = extractApplicationsAdminQuery + eventService.log( + AllApplicationsShowed, + s"Accède à la page des métadonnées des demandes [$areaOpt ; $numOfMonthsDisplayed]" + ) + Future( + Ok( + views.applicationsAdmin + .page(request.currentUser, request.rights, areaOpt, numOfMonthsDisplayed) + ) + ) + } + } + + def applicationsMetadata: Action[AnyContent] = + loginAction.async { implicit request => + (request.currentUser.admin, request.currentUser.groupAdmin) match { + case (false, false) => + eventService.log( + AllApplicationsUnauthorized, + "Liste des metadata des demandes non autorisée" + ) + Future.successful(Unauthorized(Json.toJson(ApplicationMetadataResult(Nil)))) + case _ => + val (areaOpt, numOfMonthsDisplayed) = extractApplicationsAdminQuery + allApplicationVisibleByUserAdmin(request.currentUser, areaOpt, numOfMonthsDisplayed).map { + applications => eventService.log( AllApplicationsShowed, - s"Visualise la liste des demandes de $areaId - taille = ${filteredApplications.size}" + "Accède à la liste des metadata des demandes " + + s"[territoire ${areaOpt.map(_.name).getOrElse("tous")} ; " + + s"taille : ${applications.size}]" ) - Ok( - views.html - .allApplications(request.currentUser, request.rights)( - filteredApplications, - area.getOrElse(Area.allArea) + val userIds: List[UUID] = (applications.flatMap(_.invitedUsers.keys) ++ + applications.map(_.creatorUserId)).toList.distinct + val users = userService.byIds(userIds, includeDisabled = true) + val groupIds = + (users.flatMap(_.groupIds) ::: applications.flatMap(application => + application.invitedGroupIdsAtCreation ::: application.answers.flatMap( + _.invitedGroupIds ) + )).distinct + val groups = userGroupService.byIds(groupIds) + val idToUser = users.map(user => (user.id, user)).toMap + val idToGroup = groups.map(group => (group.id, group)).toMap + val metadata = applications.map(application => + ApplicationMetadata.fromApplication( + application, + request.rights, + idToUser, + idToGroup + ) ) + Ok(Json.toJson(ApplicationMetadataResult(metadata))) } } } @@ -532,7 +576,7 @@ case class ApplicationController @Inject() ( ): Future[(List[User], List[Application])] = for { users <- userService.byGroupIdsAnonymous(groups.map(_.id)) - applications <- applicationService.allForUserIds(users.map(_.id)) + applications <- applicationService.allForUserIds(users.map(_.id), none) } yield (users, applications) private def generateStats( @@ -562,7 +606,7 @@ case class ApplicationController @Inject() ( .map(_.filter(_.areaIds.intersect(areaIds).nonEmpty)) users <- userService.byGroupIdsAnonymous(groups.map(_.id)) applications <- applicationService - .allForUserIds(users.map(_.id)) + .allForUserIds(users.map(_.id), none) .map(_.filter(application => areaIds.contains(application.area))) } yield (users, applications) case (_, _ :: _, _) => @@ -824,30 +868,6 @@ case class ApplicationController @Inject() ( .as("text/csv") } - def allCSV(areaId: UUID): Action[AnyContent] = - loginAction.async { implicit request => - val area = if (areaId === Area.allArea.id) Option.empty else Area.fromId(areaId) - val exportedApplicationsFuture = - if (request.currentUser.admin || request.currentUser.groupAdmin) { - allApplicationVisibleByUserAdmin(request.currentUser, area, 24) - } else { - Future(Nil) - } - - exportedApplicationsFuture.map { exportedApplications => - val date = Time.formatPatternFr(Time.nowParis(), "YYY-MM-dd-HH'h'mm") - val csvContent = applicationsToCSV(exportedApplications) - - eventService.log(AllCSVShowed, s"Visualise un CSV pour la zone $area") - val filenameAreaPart: String = area.map(_.name.stripSpecialChars).getOrElse("tous") - Ok(csvContent) - .withHeaders( - "Content-Disposition" -> s"""attachment; filename="aplus-demandes-$date-$filenameAreaPart.csv"""" - ) - .as("text/csv") - } - } - private def usersWhoCanBeInvitedOn(application: Application, currentAreaId: UUID)(implicit request: RequestWithUserData[_] ): Future[List[User]] = diff --git a/app/controllers/GroupController.scala b/app/controllers/GroupController.scala index f42f9176e..2d0cef259 100644 --- a/app/controllers/GroupController.scala +++ b/app/controllers/GroupController.scala @@ -407,7 +407,7 @@ case class GroupController @Inject() ( for { groups <- groupService.byIdsFuture(user.groupIds) users <- userService.byGroupIdsFuture(groups.map(_.id), includeDisabled = true) - applications <- applicationService.allForUserIds(users.map(_.id)) + applications <- applicationService.allForUserIds(users.map(_.id), none) } yield { eventService.log(EventType.EditMyGroupShowed, "Visualise la modification de ses groupes") Ok( @@ -431,7 +431,7 @@ case class GroupController @Inject() ( eventService.log(EditGroupShowed, s"Visualise la vue de modification du groupe") val isEmpty = groupService.isGroupEmpty(group.id) applicationService - .allForUserIds(groupUsers.map(_.id)) + .allForUserIds(groupUsers.map(_.id), none) .map(applications => Ok( views.html.editGroup(request.currentUser, request.rights)( diff --git a/app/controllers/JavascriptController.scala b/app/controllers/JavascriptController.scala index 2af933387..9842dec6a 100644 --- a/app/controllers/JavascriptController.scala +++ b/app/controllers/JavascriptController.scala @@ -16,7 +16,9 @@ class JavascriptController() extends InjectedController { routes.javascript.ApiController.deploymentData, routes.javascript.GroupController.deleteUnusedGroupById, routes.javascript.GroupController.editGroup, - routes.javascript.ApplicationController.all, + routes.javascript.ApplicationController.applicationsAdmin, + routes.javascript.ApplicationController.applicationsMetadata, + routes.javascript.ApplicationController.show, routes.javascript.UserController.all, routes.javascript.UserController.deleteUnusedUserById, routes.javascript.UserController.editUser, diff --git a/app/controllers/PathValidator.scala b/app/controllers/PathValidator.scala index 07173dec0..533646f14 100644 --- a/app/controllers/PathValidator.scala +++ b/app/controllers/PathValidator.scala @@ -34,7 +34,7 @@ object PathValidator { routes.AreaController.all, routes.AreaController.deploymentDashboard, routes.AreaController.franceServiceDeploymentDashboard, - routes.ApplicationController.all(placeholderUUID), + routes.ApplicationController.applicationsAdmin, routes.UserController.all(placeholderUUID), ) val uuidRegex = "([a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12})" diff --git a/app/filters/SentryFilter.scala b/app/filters/SentryFilter.scala index 70cdf0709..e91d2a4db 100644 --- a/app/filters/SentryFilter.scala +++ b/app/filters/SentryFilter.scala @@ -20,8 +20,7 @@ class SentryFilter @Inject() (implicit val mat: Materializer, ec: ExecutionConte val queryParamsWhitelist = List( Keys.QueryParam.vue, Keys.QueryParam.uniquementFs, - Keys.QueryParam.numOfMonthsDisplayed, - Keys.QueryParam.filterIsOpen + Keys.QueryParam.numOfMonthsDisplayed ) def apply( diff --git a/app/serializers/ApiModel.scala b/app/serializers/ApiModel.scala index 900b0d60c..fc6a03691 100644 --- a/app/serializers/ApiModel.scala +++ b/app/serializers/ApiModel.scala @@ -1,8 +1,10 @@ package serializers +import cats.syntax.all._ +import helper.Time import java.time.Instant import java.util.UUID -import models.{Area, Organisation, User, UserGroup} +import models.{Application, Area, Authorization, Organisation, User, UserGroup} import play.api.libs.json._ object ApiModel { @@ -168,4 +170,111 @@ object ApiModel { implicit val searchResultFormat = Json.format[SearchResult] } + // Embedded classes are here to avoid the 22 fields limit in Play Json + case class ApplicationMetadata( + id: UUID, + creationDateFormatted: String, + creationDay: String, + creatorUserName: String, + creatorUserId: UUID, + areaName: String, + pertinence: String, + internalId: Int, + closed: Boolean, + usefulness: String, + closedDateFormatted: Option[String], + closedDay: Option[String], + status: String, + currentUserCanSeeAnonymousApplication: Boolean, + creatorGroupNames: String, + invitedGroupNames: String, + stats: ApplicationMetadata.Stats, + ) + + object ApplicationMetadata { + + case class Stats( + numberOfInvitedUsers: Int, + numberOfMessages: Int, + numberOfAnswers: Int, + firstAnswerTimeInMinutes: String, + resolutionTimeInMinutes: String, + firstAnswerTimeInDays: String, + resolutionTimeInDays: String, + ) + + implicit val applicationMetadataStatsWrites = Json.writes[ApplicationMetadata.Stats] + implicit val applicationMetadataWrites = Json.writes[ApplicationMetadata] + + def fromApplication( + application: Application, + rights: Authorization.UserRights, + idToUser: Map[UUID, User], + idToGroup: Map[UUID, UserGroup] + ) = { + val areaName = Area.fromId(application.area).map(_.name).getOrElse("Sans territoire") + val pertinence = if (!application.irrelevant) "Oui" else "Non" + val creatorUser = idToUser.get(application.creatorUserId) + val creatorUserGroupNames = creatorUser.toList + .flatMap(_.groupIds) + .distinct + .flatMap(idToGroup.get) + .map(_.name) + .mkString(",") + val invitedGroupNames = application.answers + .flatMap(_.invitedGroupIds) + .distinct + .flatMap(idToGroup.get) + .map(_.name) + .mkString(",") + ApplicationMetadata( + id = application.id, + creationDateFormatted = Time.formatForAdmins(application.creationDate.toInstant), + creationDay = Time.formatPatternFr(application.creationDate, "YYY-MM-dd"), + creatorUserName = application.creatorUserName, + creatorUserId = application.creatorUserId, + areaName = areaName, + pertinence = pertinence, + internalId = application.internalId, + closed = application.closed, + usefulness = application.usefulness.getOrElse(""), + closedDateFormatted = + application.closedDate.map(date => Time.formatForAdmins(date.toInstant)), + closedDay = application.closedDate.map(date => + Time.formatPatternFr(application.creationDate, "YYY-MM-dd") + ), + status = application.status.show, + currentUserCanSeeAnonymousApplication = + Authorization.canSeeApplication(application)(rights), + creatorGroupNames = creatorUserGroupNames, + invitedGroupNames = invitedGroupNames, + stats = ApplicationMetadata.Stats( + numberOfInvitedUsers = application.invitedUsers.size, + numberOfMessages = application.answers.length + 1, + numberOfAnswers = + application.answers.count(_.creatorUserID =!= application.creatorUserId), + firstAnswerTimeInMinutes = + application.firstAnswerTimeInMinutes.map(_.toString).getOrElse(""), + resolutionTimeInMinutes = + application.resolutionTimeInMinutes.map(_.toString).getOrElse(""), + firstAnswerTimeInDays = application.firstAnswerTimeInMinutes + .map(_.toDouble / (60.0 * 24.0)) + .map(days => f"$days%.2f".reverse.dropWhile(_ === '0').reverse.stripSuffix(".")) + .getOrElse(""), + resolutionTimeInDays = application.resolutionTimeInMinutes + .map(_.toDouble / (60.0 * 24.0)) + .map(days => f"$days%.2f".reverse.dropWhile(_ === '0').reverse.stripSuffix(".")) + .getOrElse("") + ) + ) + } + + } + + case class ApplicationMetadataResult(applications: List[ApplicationMetadata]) + + object ApplicationMetadataResult { + implicit val applicationMetadataResultWrites = Json.writes[ApplicationMetadataResult] + } + } diff --git a/app/serializers/Keys.scala b/app/serializers/Keys.scala index 5559283b7..5b9dbc6a4 100644 --- a/app/serializers/Keys.scala +++ b/app/serializers/Keys.scala @@ -49,7 +49,6 @@ object Keys { val areaId: String = "areaId" val action: String = "action" val uniquementFs: String = "uniquement-fs" - val filterIsOpen: String = "filtre-ouverte" // Users diff --git a/app/services/ApplicationService.scala b/app/services/ApplicationService.scala index f35aae43f..4055f8721 100644 --- a/app/services/ApplicationService.scala +++ b/app/services/ApplicationService.scala @@ -213,14 +213,29 @@ class ApplicationService @Inject() ( } } - def allForUserIds(userIds: List[UUID]): Future[List[Application]] = + private def monthsFilter(numOfMonths: Option[Int]): String = + numOfMonths + .filter(_ >= 1) + .map(months => + "AND creation_date >= date_trunc('month', now()) - " + + s"interval '$months month'" + ) + .orEmpty + + def allForUserIds(userIds: List[UUID], numOfMonths: Option[Int]): Future[List[Application]] = Future { db.withConnection { implicit connection => + val additionalFilter = monthsFilter(numOfMonths) SQL( s"""SELECT $fieldsInSelect FROM application - WHERE ARRAY[{userIds}]::uuid[] @> ARRAY[creator_user_id]::uuid[] - OR ARRAY(select jsonb_object_keys(invited_users))::uuid[] && ARRAY[{userIds}]::uuid[] + WHERE + ( + ARRAY[{userIds}]::uuid[] @> ARRAY[creator_user_id]::uuid[] + OR + ARRAY(select jsonb_object_keys(invited_users))::uuid[] && ARRAY[{userIds}]::uuid[] + ) + $additionalFilter ORDER BY creation_date DESC""" ).on("userIds" -> userIds) .as(simpleApplication.*) @@ -248,13 +263,7 @@ class ApplicationService @Inject() ( def allForAreas(areaIds: List[UUID], numOfMonths: Option[Int]): Future[List[Application]] = Future { db.withConnection { implicit connection => - val additionalFilter = numOfMonths - .filter(_ >= 1) - .map(months => - "AND creation_date >= date_trunc('month', now()) - " + - s"interval '$months month'" - ) - .orEmpty + val additionalFilter = monthsFilter(numOfMonths) SQL(s"""SELECT $fieldsInSelect FROM application WHERE ARRAY[{areaIds}]::uuid[] @> ARRAY[area]::uuid[] diff --git a/app/views/allApplications.scala.html b/app/views/allApplications.scala.html deleted file mode 100644 index a5f206c9e..000000000 --- a/app/views/allApplications.scala.html +++ /dev/null @@ -1,129 +0,0 @@ -@import cats.implicits.catsSyntaxEq -@import _root_.helper.Time -@import serializers.Keys - -@(currentUser: User, currentUserRights: Authorization.UserRights)(applications: Seq[Application], selectedArea: Area)(implicit webJarsUtil: org.webjars.play.WebJarsUtil, flash: Flash, request: RequestHeader, mainInfos: MainInfos) - -@main(currentUser, currentUserRights, maxWidth = false)(s"Demandes") { - - -}{ - -

Liste des demandes du groupe

- -
- @if(currentUser.areas.length > 1) { -

Afficher : - -

- } - - - - Filtre demande ouvertes - -
-
- - - -
- -
- Total : @applications.size ( @applications.groupBy(_.status).view.mapValues(_.size).map{case (k,v) => @k : @v}.mkString(" / ") ) - - - - @if(currentUser.admin){ } - - @if(currentUser.admin) { - - } - - @if(currentUser.admin) { - - } - - - - - - - - - @if(currentUser.admin){ } - - - - - - - - - @for((application) <- applications) { - - @if(currentUser.admin){ } - - @if(currentUser.admin) { - - } - - @if(currentUser.admin) { - - } - - - - - - - - - @if(currentUser.admin){ } - - } - -
#DateTerritoiresAvancementCréateurInvitésMessagesRéponsesUtileNon pertinenteSujetCatégorieDétails
@application.internalId@Time.formatForAdmins(application.creationDate.toInstant)@Area.fromId(application.area).get.name@application.status@application.creatorUserName@application.invitedUsers.size@(application.answers.length + 1)@application.answers.count(_.creatorUserID != application.creatorUserId)@application.usefulness match { - case Some("Oui") => { Oui} - case Some("Je ne sais pas") => { Je ne sais pas} - case Some("Non") => { Non} - case _ => { ? } - }@if(application.irrelevant) { Oui } else { Non } @application.subject@application.category.getOrElse("") - info_outline -
-
-
-}{ - -} diff --git a/app/views/allArea.scala.html b/app/views/allArea.scala.html index 9040a3d30..61385350a 100644 --- a/app/views/allArea.scala.html +++ b/app/views/allArea.scala.html @@ -18,7 +18,7 @@ @if(areasWithLoginByKey.contains[UUID](area.id)) { warning Login par clé possiblewarning / } - + Voir les demandes / diff --git a/app/views/applicationsAdmin.scala b/app/views/applicationsAdmin.scala new file mode 100644 index 000000000..5ab1c2daa --- /dev/null +++ b/app/views/applicationsAdmin.scala @@ -0,0 +1,94 @@ +package views + +import cats.syntax.all._ +import helper.TwirlImports.toHtml +import java.util.UUID +import models.{Area, Authorization, User} +import org.webjars.play.WebJarsUtil +import play.api.mvc.{Flash, RequestHeader} +import play.twirl.api.Html +import scalatags.Text.all._ + +object applicationsAdmin { + + def page( + currentUser: User, + currentUserRights: Authorization.UserRights, + selectedArea: Option[Area], + numOfMonthsDisplayed: Int, + )(implicit + flash: Flash, + request: RequestHeader, + webJarsUtil: WebJarsUtil, + mainInfos: MainInfos + ): Html = + views.html.main(currentUser, currentUserRights, maxWidth = false)( + s"Metadonnées des demandes" + )(Nil)( + frag( + areaSelect("applications-area-id", currentUser, selectedArea.getOrElse(Area.allArea).id), + div( + cls := "mdl-cell mdl-cell--3-col", + label( + `for` := "num-of-months-displayed-box", + "Nombre de mois : " + ), + input( + `type` := "number", + id := "num-of-months-displayed-box", + cls := "single--width-48px", + name := "num-of-months-displayed-box", + value := numOfMonthsDisplayed.toString + ), + ), + div( + cls := "mdl-cell mdl-cell--3-col", + a( + id := "applications-download-btn-csv", + href := "#", + i(cls := "fas fa-download"), + " Téléchargement au format CSV" + ) + ), + div( + cls := "mdl-cell mdl-cell--3-col", + a( + id := "applications-download-btn-xlsx", + href := "#", + i(cls := "fas fa-download"), + " Téléchargement au format XLSX" + ) + ), + div(cls := "mdl-cell mdl-cell--12-col", id := "tabulator-applications-table"), + ) + )( + views.helpers.head.publicScript("generated-js/xlsx.full.min.js") + ) + + def areaSelect(selectId: String, currentUser: User, selectedAreaId: UUID): Frag = + (currentUser.areas.length > 1).some + .filter(identity) + .map(_ => + p( + cls := "mdl-cell mdl-cell--3-col", + "Territoire : ", + select( + id := selectId, + name := "area-selector", + frag( + (Area.allArea :: currentUser.areas.flatMap(Area.fromId)).map(area => + option( + value := area.id.toString, + (area.id === selectedAreaId).some.filter(identity).map(_ => selected), + if (area.id === Area.allArea.id) + "Tous les territoires" + else + area.toString + ) + ) + ) + ) + ) + ) + +} diff --git a/app/views/helpers/menuNav.scala.html b/app/views/helpers/menuNav.scala.html index 1c8c0c9ff..0cabc5515 100644 --- a/app/views/helpers/menuNav.scala.html +++ b/app/views/helpers/menuNav.scala.html @@ -30,7 +30,7 @@ } @if(Authorization.canSeeApplicationsAsAdmin(currentUserRights)) { - + folderAdmin : demandes } diff --git a/app/views/home/page.scala.html b/app/views/home/page.scala.html index 1bd192a58..46e4e962a 100644 --- a/app/views/home/page.scala.html +++ b/app/views/home/page.scala.html @@ -361,6 +361,7 @@

+ diff --git a/app/views/magicLinkAntiConsumptionPage.scala.html b/app/views/magicLinkAntiConsumptionPage.scala.html index 3c7d59200..49ea88e7f 100644 --- a/app/views/magicLinkAntiConsumptionPage.scala.html +++ b/app/views/magicLinkAntiConsumptionPage.scala.html @@ -54,6 +54,7 @@

Vous allez accéder automatiquement à Administration+

@webJarsUtil.locate("material.min.js").script() + diff --git a/conf/routes b/conf/routes index 2c53101c3..29bbcf955 100644 --- a/conf/routes +++ b/conf/routes @@ -24,6 +24,8 @@ POST /toutes-les-demandes/:applicationId/reopen GET /toutes-les-demandes/:applicationId/fichiers/:fileId controllers.ApplicationController.applicationFile(applicationId: java.util.UUID, fileId: String) GET /toutes-les-demandes/:applicationId/messages/:answerId/fichiers/:fileId controllers.ApplicationController.answerFile(applicationId: java.util.UUID, answerId: java.util.UUID, fileId: String) +GET /demandes/metadonnees controllers.ApplicationController.applicationsAdmin + # Mandat GET /mandats/:id controllers.MandatController.mandat(id: java.util.UUID) @@ -101,8 +103,6 @@ POST /newsletter GET /territoires controllers.AreaController.all GET /territoires/deploiement controllers.AreaController.deploymentDashboard GET /territoires/deploiement/france-service controllers.AreaController.franceServiceDeploymentDashboard -GET /territoires/:areaId/demandes controllers.ApplicationController.all(areaId: java.util.UUID) -GET /territoires/:areaId/demandes.csv controllers.ApplicationController.allCSV(areaId: java.util.UUID) GET /territoires/:areaId/utilisateurs controllers.UserController.all(areaId: java.util.UUID) GET /territoires/:areaId/utilisateurs.csv controllers.UserController.allCSV(areaId: java.util.UUID) @@ -114,6 +114,7 @@ GET /status GET /api/deploiement/france-service controllers.ApiController.franceServiceDeployment GET /api/deploiement/operateurs controllers.ApiController.deploymentData GET /api/search controllers.UserController.search +GET /api/applications/metadata controllers.ApplicationController.applicationsMetadata # Javascript resource GET /jsRoutes controllers.JavascriptController.javascriptRoutes diff --git a/public/stylesheets/main.css b/public/stylesheets/main.css index 1663eca85..da571d65c 100644 --- a/public/stylesheets/main.css +++ b/public/stylesheets/main.css @@ -784,6 +784,10 @@ a { width: 100%; } +.single--width-48px { + width: 48px; +} + .single--width-max-content { width: max-content; } diff --git a/typescript/src/applicationsAdmin.ts b/typescript/src/applicationsAdmin.ts new file mode 100644 index 000000000..2dacf5790 --- /dev/null +++ b/typescript/src/applicationsAdmin.ts @@ -0,0 +1,308 @@ +/* global jsRoutes */ +import { Tabulator, TabulatorFull } from 'tabulator-tables'; +import "tabulator-tables/dist/css/tabulator.css"; + +const applicationsTableId = "tabulator-applications-table"; +const applicationsAreaId = "applications-area-id"; +const applicationsNumOfMonthsDisplayedId = "num-of-months-displayed-box"; +const downloadBtnCsv = "applications-download-btn-csv"; +const downloadBtnXlsx = "applications-download-btn-xlsx"; +const queryParamAreaId = "areaId"; +const queryParamNumOfMonthsDisplayed = "nombreDeMoisAffiche"; + + + +if (window.document.getElementById(applicationsTableId)) { + + + + let applicationsTable: Tabulator | null = null; + + const ajaxUrl: string = jsRoutes.controllers.ApplicationController.applicationsMetadata().url; + + + + const extractQueryParams: () => { areaId: string | null, nombreDeMoisAffiche: string } = () => { + const params = new URL(window.location.href).searchParams; + const areaIdOpt = params.get(queryParamAreaId); + const numOfMonthsDisplayedOpt = params.get(queryParamNumOfMonthsDisplayed); + return { + areaId: areaIdOpt, + nombreDeMoisAffiche: numOfMonthsDisplayedOpt ? numOfMonthsDisplayedOpt : "3", + }; + }; + + // Changed by event listeners + let ajaxParams: { areaId: string | null, nombreDeMoisAffiche: string } = extractQueryParams(); + + + + // Set the initial area id + const areaSelect = document.getElementById(applicationsAreaId); + if (areaSelect) { + areaSelect.addEventListener('change', (e) => { + const target = e.target as HTMLSelectElement; + const id: string = target.value; + ajaxParams.areaId = id; + applicationsTable?.setData(ajaxUrl); + }); + } + + // Set number of months + const monthsInput = document.getElementById(applicationsNumOfMonthsDisplayedId); + if (monthsInput) { + monthsInput.addEventListener('input', (e) => { + const target = e.target as HTMLInputElement; + const num: string = target.value; + ajaxParams.nombreDeMoisAffiche = num; + applicationsTable?.setData(ajaxUrl); + }); + } + + + + // Download + const downloadFilename: () => string = () => { + let areaName: string = ""; + if (areaSelect) { + const selected = areaSelect.options[areaSelect.selectedIndex]; + if (selected && selected.text) { + areaName = selected.text + " - "; + } + } + const date = new Date().toLocaleDateString('fr-fr', { year: "numeric", month: "numeric", day: "numeric" }); + const filename = 'Demandes Administration+ - ' + areaName + date; + return filename; + }; + + const csvDownloadBtn = document.getElementById(downloadBtnCsv); + if (csvDownloadBtn) { + csvDownloadBtn.addEventListener('click', (_) => { + applicationsTable?.download( + 'csv', + downloadFilename() + '.csv', + { delimiter: ";" } + ); + }); + } + + const xlsxDownloadBtn = document.getElementById(downloadBtnXlsx); + if (xlsxDownloadBtn) { + xlsxDownloadBtn.addEventListener('click', (_) => { + applicationsTable?.download( + 'xlsx', + downloadFilename() + '.xlsx', + { sheetName: "Demandes Administration+" } + ); + }); + } + + + + // Setup Tabulator + const linkFormatter: Tabulator.Formatter = (cell) => { + let uuid = cell.getRow().getData().id; + let authorized = cell.getRow().getData().currentUserCanSeeAnonymousApplication; + let url = jsRoutes.controllers.ApplicationController.show(uuid).url; + if (authorized) { + return ""; + } else { + return ""; + } + }; + + const usefulnessFormatter: Tabulator.Formatter = (cell) => { + let value = cell.getValue(); + if (value) { + if (value === "Oui") { + cell.getElement().classList.add("mdl-color--light-green"); + } else if (value === "Non") { + cell.getElement().classList.add("mdl-color--red"); + } + } + return value; + }; + + const pertinenceFormatter: Tabulator.Formatter = (cell) => { + let value = cell.getValue(); + if (value && value === "Non") { + cell.getElement().classList.add("mdl-color--red"); + } + return value; + }; + + const usersColumns: Array = [ + { + title: "", + field: "id", + formatter: linkFormatter, + hozAlign: "center", + bottomCalc: "count", + frozen: true + }, + { + title: "No", + field: "internalId", + headerFilter: "input", + hozAlign: "right", + sorter: "number", + bottomCalc: "count", + titleDownload: "Numéro" + }, + { + title: "Création", + field: "creationDateFormatted", + headerFilter: "input", + download: false + }, + { + title: "Date de création", + field: "creationDay", + visible: false, + download: true + }, + { + title: "Territoire", + field: "areaName", + headerFilter: "select", + headerFilterParams: { values: true, multiselect: true } + }, + { + title: "Avancement", + field: "status", + headerFilter: "select", + headerFilterParams: { values: true, multiselect: true } + }, + { + title: "Créateur", + field: "creatorUserName", + headerFilter: "input", + maxWidth: 300, + }, + { + title: "Invités", + field: "stats.numberOfInvitedUsers", + hozAlign: "right", + headerFilter: "input", + sorter: "number" + }, + { + title: "Messages", + field: "stats.numberOfMessages", + hozAlign: "right", + headerFilter: "input", + sorter: "number" + }, + { + title: "Réponses", + field: "stats.numberOfAnswers", + hozAlign: "right", + headerFilter: "input", + sorter: "number" + }, + { + title: "Utile", + field: "usefulness", + hozAlign: "center", + formatter: usefulnessFormatter, + headerFilter: "select", + headerFilterParams: { values: true, multiselect: true } + }, + { + title: "Pertinente", + field: "pertinence", + hozAlign: "center", + formatter: pertinenceFormatter, + headerFilter: "select", + headerFilterParams: { values: true, multiselect: true } + }, + { + title: "Clôture", + field: "closedDateFormatted", + headerFilter: "input", + download: false + }, + { + title: "Date de clôture", + field: "closedDay", + visible: false, + download: true + }, + { + title: "Groupes du demandeur", + field: "creatorGroupNames", + headerFilter: "input", + maxWidth: 300, + }, + { + title: "Groupes invités", + field: "invitedGroupNames", + headerFilter: "input", + maxWidth: 300, + }, + { + title: "Délais de première réponse (Jours)", + field: "stats.firstAnswerTimeInDays", + hozAlign: "right", + headerFilter: "input", + sorter: "number", + bottomCalc: "avg", + }, + { + title: "Délais de clôture (Jours)", + field: "stats.resolutionTimeInDays", + hozAlign: "right", + headerFilter: "input", + sorter: "number", + bottomCalc: "avg", + }, + { + title: "Délais de première réponse (Minutes)", + field: "stats.firstAnswerTimeInMinutes", + hozAlign: "right", + headerFilter: "input", + sorter: "number", + bottomCalc: "avg", + visible: false, + download: true + }, + { + title: "Délais de clôture (Minutes)", + field: "stats.resolutionTimeInMinutes", + hozAlign: "right", + headerFilter: "input", + sorter: "number", + bottomCalc: "avg", + visible: false, + download: true + }, + ]; + + const usersOptions: Tabulator.Options = { + height: "75vh", + langs: { + "fr-fr": { + "data": { + "loading": "Chargement", + "error": "Erreur", + }, + "headerFilters": { + "default": "filtrer..." + } + } + }, + columns: usersColumns, + ajaxURL: ajaxUrl, + ajaxParams: () => ajaxParams, + ajaxResponse(_url, _params, response) { + return response.applications; + } + }; + applicationsTable = new TabulatorFull("#" + applicationsTableId, usersOptions); + applicationsTable.on("tableBuilt", function() { + applicationsTable?.setLocale("fr-fr"); + // Weird behevior: setSort throws TypeError if applied now + setTimeout(() => applicationsTable?.setSort("internalId", "desc"), 2000); + }); + +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 8121d35da..2f5eeb1f2 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -10,6 +10,9 @@ import 'ts-polyfill/lib/es2017-object'; // This adds NodeList.forEach, etc. import 'core-js/web/dom-collections'; +// URLSearchParams +import 'core-js/stable/url'; +import 'core-js/stable/url-search-params'; // fetch import 'unfetch/polyfill'; // String.prototype.normalize @@ -22,6 +25,7 @@ import "./admin"; import "./application"; import "./applicationMandatFields"; import "./applicationAttachment"; +import "./applicationsAdmin"; //import "./autolinker"; import "./changeArea"; import "./domHelpers";