diff --git a/src/main/java/de/rwth/idsg/steve/repository/OcppTagRepository.java b/src/main/java/de/rwth/idsg/steve/repository/OcppTagRepository.java index c446ad21a..e3dc94f79 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/OcppTagRepository.java +++ b/src/main/java/de/rwth/idsg/steve/repository/OcppTagRepository.java @@ -40,6 +40,7 @@ public interface OcppTagRepository { OcppTagActivityRecord getRecord(int ocppTagPk); List getIdTags(); + List getIdTagsWithoutUser(); List getActiveIdTags(); List getParentIdTags(); diff --git a/src/main/java/de/rwth/idsg/steve/repository/dto/User.java b/src/main/java/de/rwth/idsg/steve/repository/dto/User.java index 9eb43ee4a..b06dfb8e4 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/dto/User.java +++ b/src/main/java/de/rwth/idsg/steve/repository/dto/User.java @@ -22,8 +22,9 @@ import jooq.steve.db.tables.records.UserRecord; import lombok.Builder; import lombok.Getter; +import lombok.RequiredArgsConstructor; -import java.util.Optional; +import java.util.List; /** * @author Sevket Goekay @@ -34,8 +35,9 @@ public class User { @Getter @Builder public static final class Overview { - private final Integer userPk, ocppTagPk; - private final String ocppIdTag, name, phone, email; + private final Integer userPk; + private final String name, phone, email; + private final List ocppTagEntries; } @Getter @@ -43,6 +45,14 @@ public static final class Overview { public static final class Details { private final UserRecord userRecord; private final AddressRecord address; - private Optional ocppIdTag; + private final List ocppTagEntries; } + + @Getter + @RequiredArgsConstructor + public static final class OcppTagEntry { + private final Integer ocppTagPk; + private final String idTag; + } + } diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/OcppTagRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/OcppTagRepositoryImpl.java index 95696f449..e02b1a07f 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/OcppTagRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/OcppTagRepositoryImpl.java @@ -48,6 +48,7 @@ import static de.rwth.idsg.steve.utils.DateTimeUtils.toDateTime; import static jooq.steve.db.tables.OcppTag.OCPP_TAG; import static jooq.steve.db.tables.OcppTagActivity.OCPP_TAG_ACTIVITY; +import static jooq.steve.db.tables.UserOcppTag.USER_OCPP_TAG; /** * @author Sevket Goekay @@ -161,6 +162,16 @@ public List getIdTags() { .fetch(OCPP_TAG.ID_TAG); } + @Override + public List getIdTagsWithoutUser() { + return ctx.select(OCPP_TAG.ID_TAG) + .from(OCPP_TAG) + .leftJoin(USER_OCPP_TAG).on(OCPP_TAG.OCPP_TAG_PK.eq(USER_OCPP_TAG.OCPP_TAG_PK)) + .where(USER_OCPP_TAG.OCPP_TAG_PK.isNull()) + .orderBy(OCPP_TAG.ID_TAG) + .fetch(OCPP_TAG.ID_TAG); + } + @Override public List getActiveIdTags() { return ctx.select(OCPP_TAG_ACTIVITY.ID_TAG) diff --git a/src/main/java/de/rwth/idsg/steve/repository/impl/UserRepositoryImpl.java b/src/main/java/de/rwth/idsg/steve/repository/impl/UserRepositoryImpl.java index f37ab73b5..5369c72af 100644 --- a/src/main/java/de/rwth/idsg/steve/repository/impl/UserRepositoryImpl.java +++ b/src/main/java/de/rwth/idsg/steve/repository/impl/UserRepositoryImpl.java @@ -18,32 +18,35 @@ */ package de.rwth.idsg.steve.repository.impl; +import com.google.common.base.Strings; import de.rwth.idsg.steve.SteveException; import de.rwth.idsg.steve.repository.AddressRepository; import de.rwth.idsg.steve.repository.UserRepository; import de.rwth.idsg.steve.repository.dto.User; import de.rwth.idsg.steve.web.dto.UserForm; import de.rwth.idsg.steve.web.dto.UserQueryForm; -import jooq.steve.db.tables.records.AddressRecord; import jooq.steve.db.tables.records.UserRecord; import lombok.extern.slf4j.Slf4j; +import org.jooq.Condition; import org.jooq.DSLContext; import org.jooq.Field; -import org.jooq.JoinType; import org.jooq.Record1; -import org.jooq.Record7; +import org.jooq.Record5; import org.jooq.Result; import org.jooq.SelectConditionStep; -import org.jooq.SelectQuery; import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; +import org.springframework.util.CollectionUtils; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; -import java.util.Optional; +import java.util.Map; import static de.rwth.idsg.steve.utils.CustomDSL.includes; +import static jooq.steve.db.Tables.USER_OCPP_TAG; import static jooq.steve.db.tables.OcppTag.OCPP_TAG; import static jooq.steve.db.tables.User.USER; @@ -60,25 +63,21 @@ public class UserRepositoryImpl implements UserRepository { @Override public List getOverview(UserQueryForm form) { + var ocppTagsPerUser = getOcppTagsInternal(form.getUserPk(), form.getOcppIdTag()); + return getOverviewInternal(form) .map(r -> User.Overview.builder() .userPk(r.value1()) - .ocppTagPk(r.value2()) - .ocppIdTag(r.value3()) - .name(r.value4() + " " + r.value5()) - .phone(r.value6()) - .email(r.value7()) + .name(r.value2() + " " + r.value3()) + .phone(r.value4()) + .email(r.value5()) + .ocppTagEntries(ocppTagsPerUser.getOrDefault(r.value1(), List.of())) .build() ); } @Override public User.Details getDetails(int userPk) { - - // ------------------------------------------------------------------------- - // 1. user table - // ------------------------------------------------------------------------- - UserRecord ur = ctx.selectFrom(USER) .where(USER.USER_PK.equal(userPk)) .fetchOne(); @@ -87,32 +86,10 @@ public User.Details getDetails(int userPk) { throw new SteveException("There is no user with id '%s'", userPk); } - // ------------------------------------------------------------------------- - // 2. address table - // ------------------------------------------------------------------------- - - AddressRecord ar = addressRepository.get(ctx, ur.getAddressPk()); - - // ------------------------------------------------------------------------- - // 3. ocpp_tag table - // ------------------------------------------------------------------------- - - String ocppIdTag = null; - if (ur.getOcppTagPk() != null) { - Record1 record = ctx.select(OCPP_TAG.ID_TAG) - .from(OCPP_TAG) - .where(OCPP_TAG.OCPP_TAG_PK.eq(ur.getOcppTagPk())) - .fetchOne(); - - if (record != null) { - ocppIdTag = record.value1(); - } - } - return User.Details.builder() .userRecord(ur) - .address(ar) - .ocppIdTag(Optional.ofNullable(ocppIdTag)) + .address(addressRepository.get(ctx, ur.getAddressPk())) + .ocppTagEntries(getOcppTagsInternal(userPk, null).getOrDefault(userPk, List.of())) .build(); } @@ -122,7 +99,8 @@ public void add(UserForm form) { DSLContext ctx = DSL.using(configuration); try { Integer addressId = addressRepository.updateOrInsert(ctx, form.getAddress()); - addInternal(ctx, form, addressId); + Integer userPk = addInternal(ctx, form, addressId); + refreshOcppTagsInternal(ctx, form, userPk); } catch (DataAccessException e) { throw new SteveException("Failed to add the user", e); @@ -137,6 +115,7 @@ public void update(UserForm form) { try { Integer addressId = addressRepository.updateOrInsert(ctx, form.getAddress()); updateInternal(ctx, form, addressId); + refreshOcppTagsInternal(ctx, form, form.getUserPk()); } catch (DataAccessException e) { throw new SteveException("Failed to update the user", e); @@ -162,44 +141,73 @@ public void delete(int userPk) { // Private helpers // ------------------------------------------------------------------------- - @SuppressWarnings("unchecked") - private Result> getOverviewInternal(UserQueryForm form) { - SelectQuery selectQuery = ctx.selectQuery(); - selectQuery.addFrom(USER); - selectQuery.addJoin(OCPP_TAG, JoinType.LEFT_OUTER_JOIN, USER.OCPP_TAG_PK.eq(OCPP_TAG.OCPP_TAG_PK)); - selectQuery.addSelect( - USER.USER_PK, - USER.OCPP_TAG_PK, - OCPP_TAG.ID_TAG, - USER.FIRST_NAME, - USER.LAST_NAME, - USER.PHONE, - USER.E_MAIL - ); + private Result> getOverviewInternal(UserQueryForm form) { + List conditions = new ArrayList<>(); if (form.isSetUserPk()) { - selectQuery.addConditions(USER.USER_PK.eq(form.getUserPk())); + conditions.add(USER.USER_PK.eq(form.getUserPk())); } - if (form.isSetOcppIdTag()) { - selectQuery.addConditions(includes(OCPP_TAG.ID_TAG, form.getOcppIdTag())); + if (form.isSetEmail()) { + conditions.add(includes(USER.E_MAIL, form.getEmail())); } - if (form.isSetEmail()) { - selectQuery.addConditions(includes(USER.E_MAIL, form.getEmail())); + if (form.isSetOcppIdTag()) { + conditions.add(DSL.exists( + DSL.selectOne() + .from(USER_OCPP_TAG) + .join(OCPP_TAG).on(USER_OCPP_TAG.OCPP_TAG_PK.eq(OCPP_TAG.OCPP_TAG_PK)) + .where(USER_OCPP_TAG.USER_PK.eq(USER.USER_PK)) + .and(includes(OCPP_TAG.ID_TAG, form.getOcppIdTag())) + )); } if (form.isSetName()) { - // Concatenate the two columns and search within the resulting representation // for flexibility, since the user can search by first or last name, or both. Field joinedField = DSL.concat(USER.FIRST_NAME, USER.LAST_NAME); // Find a matching sequence anywhere within the concatenated representation - selectQuery.addConditions(includes(joinedField, form.getName())); + conditions.add(includes(joinedField, form.getName())); + } + + return ctx.select( + USER.USER_PK, + USER.FIRST_NAME, + USER.LAST_NAME, + USER.PHONE, + USER.E_MAIL) + .from(USER) + .where(conditions) + .fetch(); + } + + private Map> getOcppTagsInternal(Integer userPk, String ocppIdTag) { + List conditions = new ArrayList<>(); + + if (userPk != null) { + conditions.add(USER_OCPP_TAG.USER_PK.eq(userPk)); + } + + if (!Strings.isNullOrEmpty(ocppIdTag)) { + conditions.add(includes(OCPP_TAG.ID_TAG, ocppIdTag)); } - return selectQuery.fetch(); + var results = ctx.select( + USER_OCPP_TAG.USER_PK, + OCPP_TAG.OCPP_TAG_PK, + OCPP_TAG.ID_TAG) + .from(USER_OCPP_TAG) + .join(OCPP_TAG).on(USER_OCPP_TAG.OCPP_TAG_PK.eq(OCPP_TAG.OCPP_TAG_PK)) + .where(conditions) + .fetch(); + + Map> map = new HashMap<>(); + for (var entry : results) { + map.computeIfAbsent(entry.value1(), k -> new ArrayList<>()) + .add(new User.OcppTagEntry(entry.value2(), entry.value3())); + } + return map; } private SelectConditionStep> selectAddressId(int userPk) { @@ -214,21 +222,22 @@ private SelectConditionStep> selectOcppTagPk(String ocppIdTag) .where(OCPP_TAG.ID_TAG.eq(ocppIdTag)); } - private void addInternal(DSLContext ctx, UserForm form, Integer addressPk) { - int count = ctx.insertInto(USER) - .set(USER.FIRST_NAME, form.getFirstName()) - .set(USER.LAST_NAME, form.getLastName()) - .set(USER.BIRTH_DAY, form.getBirthDay()) - .set(USER.SEX, form.getSex().getDatabaseValue()) - .set(USER.PHONE, form.getPhone()) - .set(USER.E_MAIL, form.getEMail()) - .set(USER.NOTE, form.getNote()) - .set(USER.ADDRESS_PK, addressPk) - .set(USER.OCPP_TAG_PK, selectOcppTagPk(form.getOcppIdTag())) - .execute(); - - if (count != 1) { - throw new SteveException("Failed to insert the user"); + private Integer addInternal(DSLContext ctx, UserForm form, Integer addressPk) { + try { + return ctx.insertInto(USER) + .set(USER.FIRST_NAME, form.getFirstName()) + .set(USER.LAST_NAME, form.getLastName()) + .set(USER.BIRTH_DAY, form.getBirthDay()) + .set(USER.SEX, form.getSex().getDatabaseValue()) + .set(USER.PHONE, form.getPhone()) + .set(USER.E_MAIL, form.getEMail()) + .set(USER.NOTE, form.getNote()) + .set(USER.ADDRESS_PK, addressPk) + .returning(USER.USER_PK) + .fetchOne() + .getUserPk(); + } catch (DataAccessException e) { + throw new SteveException("Failed to insert the user", e); } } @@ -242,7 +251,6 @@ private void updateInternal(DSLContext ctx, UserForm form, Integer addressPk) { .set(USER.E_MAIL, form.getEMail()) .set(USER.NOTE, form.getNote()) .set(USER.ADDRESS_PK, addressPk) - .set(USER.OCPP_TAG_PK, selectOcppTagPk(form.getOcppIdTag())) .where(USER.USER_PK.eq(form.getUserPk())) .execute(); } @@ -252,4 +260,37 @@ private void deleteInternal(DSLContext ctx, int userPk) { .where(USER.USER_PK.equal(userPk)) .execute(); } + + /** + * Refresh the full the OCPP tag associations for a user: + * - 1. Delete existing OCPP tags that are not in the form. + * - 2. Insert new OCPP tags from the form that do not already exist for the user. + */ + private void refreshOcppTagsInternal(DSLContext ctx, UserForm form, Integer userPk) { + List wantedOcppTagPks = CollectionUtils.isEmpty(form.getIdTagList()) + ? List.of() // This user wants no OCPP tags + : ctx.select(OCPP_TAG.OCPP_TAG_PK) + .from(OCPP_TAG) + .where(OCPP_TAG.ID_TAG.in(form.getIdTagList())) + .fetch(OCPP_TAG.OCPP_TAG_PK); + + // Optimization: Execute the delete query only if we are processing an existing user. + // A new user will not have any existing OCPP tags, so no delete is needed. + // + // 1. Delete entries that are not in the wanted entries + if (form.getUserPk() != null) { + ctx.deleteFrom(USER_OCPP_TAG) + .where(USER_OCPP_TAG.USER_PK.eq(userPk)) + .and(USER_OCPP_TAG.OCPP_TAG_PK.notIn(wantedOcppTagPks)) + .execute(); + } + + // 2. Insert new entries that are not already present + if (!wantedOcppTagPks.isEmpty()) { + ctx.insertInto(USER_OCPP_TAG, USER_OCPP_TAG.USER_PK, USER_OCPP_TAG.OCPP_TAG_PK) + .valuesOfRows(wantedOcppTagPks.stream().map(pk -> DSL.row(userPk, pk)).toList()) + .onDuplicateKeyIgnore() // Ignore if already exists + .execute(); + } + } } diff --git a/src/main/java/de/rwth/idsg/steve/service/OcppTagService.java b/src/main/java/de/rwth/idsg/steve/service/OcppTagService.java index 5bfcb5138..61e32a513 100644 --- a/src/main/java/de/rwth/idsg/steve/service/OcppTagService.java +++ b/src/main/java/de/rwth/idsg/steve/service/OcppTagService.java @@ -67,6 +67,10 @@ public List getIdTags() { return ocppTagRepository.getIdTags(); } + public List getIdTagsWithoutUser() { + return ocppTagRepository.getIdTagsWithoutUser(); + } + public List getActiveIdTags() { return ocppTagRepository.getActiveIdTags(); } diff --git a/src/main/java/de/rwth/idsg/steve/utils/mapper/UserFormMapper.java b/src/main/java/de/rwth/idsg/steve/utils/mapper/UserFormMapper.java index c35473355..8daab4394 100644 --- a/src/main/java/de/rwth/idsg/steve/utils/mapper/UserFormMapper.java +++ b/src/main/java/de/rwth/idsg/steve/utils/mapper/UserFormMapper.java @@ -19,7 +19,6 @@ package de.rwth.idsg.steve.utils.mapper; import de.rwth.idsg.steve.repository.dto.User; -import de.rwth.idsg.steve.utils.ControllerHelper; import de.rwth.idsg.steve.web.dto.UserForm; import de.rwth.idsg.steve.web.dto.UserSex; import jooq.steve.db.tables.records.UserRecord; @@ -46,7 +45,7 @@ public static UserForm toForm(User.Details details) { form.setEMail(userRecord.getEMail()); form.setNote(userRecord.getNote()); form.setAddress(AddressMapper.recordToDto(details.getAddress())); - form.setOcppIdTag(details.getOcppIdTag().orElse(ControllerHelper.EMPTY_OPTION)); + form.setIdTagList(details.getOcppTagEntries().stream().map(User.OcppTagEntry::getIdTag).toList()); return form; } diff --git a/src/main/java/de/rwth/idsg/steve/web/controller/UsersController.java b/src/main/java/de/rwth/idsg/steve/web/controller/UsersController.java index 6bfa29fb8..c7d6a4ca0 100644 --- a/src/main/java/de/rwth/idsg/steve/web/controller/UsersController.java +++ b/src/main/java/de/rwth/idsg/steve/web/controller/UsersController.java @@ -36,6 +36,9 @@ import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.List; + /** * @author Sevket Goekay * @since 25.11.2015 @@ -87,13 +90,13 @@ public String getDetails(@PathVariable("userPk") int userPk, Model model) { UserForm form = UserFormMapper.toForm(details); model.addAttribute("userForm", form); - setTags(model); + setTags(model, form.getIdTagList()); return "data-man/userDetails"; } @RequestMapping(value = ADD_PATH, method = RequestMethod.GET) public String addGet(Model model) { - setTags(model); + setTags(model, List.of()); model.addAttribute("userForm", new UserForm()); return "data-man/userAdd"; } @@ -102,7 +105,7 @@ public String addGet(Model model) { public String addPost(@Valid @ModelAttribute("userForm") UserForm userForm, BindingResult result, Model model) { if (result.hasErrors()) { - setTags(model); + setTags(model, userForm.getIdTagList()); return "data-man/userAdd"; } @@ -114,7 +117,7 @@ public String addPost(@Valid @ModelAttribute("userForm") UserForm userForm, public String update(@Valid @ModelAttribute("userForm") UserForm userForm, BindingResult result, Model model) { if (result.hasErrors()) { - setTags(model); + setTags(model, userForm.getIdTagList()); return "data-man/userDetails"; } @@ -128,9 +131,16 @@ public String delete(@PathVariable("userPk") int userPk) { return toOverview(); } - private void setTags(Model model) { + private void setTags(Model model, List idTagsFromUser) { + List fromDB = ocppTagService.getIdTagsWithoutUser(); + + // new temp list because we want to have a specific order + List idTagList = new ArrayList<>(fromDB.size() + idTagsFromUser.size()); + idTagList.addAll(idTagsFromUser); + idTagList.addAll(fromDB); + model.addAttribute("countryCodes", ControllerHelper.COUNTRY_DROPDOWN); - model.addAttribute("idTagList", ControllerHelper.idTagEnhancer(ocppTagService.getIdTags())); + model.addAttribute("idTagList", idTagList); } // ------------------------------------------------------------------------- diff --git a/src/main/java/de/rwth/idsg/steve/web/dto/UserForm.java b/src/main/java/de/rwth/idsg/steve/web/dto/UserForm.java index 760ea855f..e3bee9af4 100644 --- a/src/main/java/de/rwth/idsg/steve/web/dto/UserForm.java +++ b/src/main/java/de/rwth/idsg/steve/web/dto/UserForm.java @@ -26,6 +26,9 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotNull; +import java.util.Collections; +import java.util.List; + /** * @author Sevket Goekay * @since 25.11.2015 @@ -38,7 +41,7 @@ public class UserForm { // Internal database id private Integer userPk; - private String ocppIdTag; + private List idTagList = Collections.emptyList(); private String firstName; private String lastName; diff --git a/src/main/java/de/rwth/idsg/steve/web/dto/UserQueryForm.java b/src/main/java/de/rwth/idsg/steve/web/dto/UserQueryForm.java index e3201fd4f..57bcdaeb9 100644 --- a/src/main/java/de/rwth/idsg/steve/web/dto/UserQueryForm.java +++ b/src/main/java/de/rwth/idsg/steve/web/dto/UserQueryForm.java @@ -18,6 +18,7 @@ */ package de.rwth.idsg.steve.web.dto; +import com.google.common.base.Strings; import lombok.Getter; import lombok.Setter; import lombok.ToString; @@ -43,7 +44,7 @@ public boolean isSetUserPk() { } public boolean isSetOcppIdTag() { - return ocppIdTag != null; + return !Strings.isNullOrEmpty(ocppIdTag); } public boolean isSetName() { diff --git a/src/main/resources/db/migration/V1_0_8__update.sql b/src/main/resources/db/migration/V1_0_8__update.sql new file mode 100644 index 000000000..36973b28d --- /dev/null +++ b/src/main/resources/db/migration/V1_0_8__update.sql @@ -0,0 +1,37 @@ +START TRANSACTION; + +-- 1. create user_ocpp_tag table where a user can have multiple ocpp_tags +CREATE TABLE user_ocpp_tag +( + user_pk int(11) NOT NULL, + ocpp_tag_pk int(11) NOT NULL, + + PRIMARY KEY (user_pk, ocpp_tag_pk), + + -- ensure that each ocpp_tag can only be assigned to one user + -- + -- IMPORTANT! previous ocpp_tag_pk column in user table was not unique (more than one user could have + -- the same ocpp_tag), so this is a change. + -- + UNIQUE (ocpp_tag_pk), + + FOREIGN KEY (user_pk) REFERENCES user(user_pk) ON DELETE CASCADE, + FOREIGN KEY (ocpp_tag_pk) REFERENCES ocpp_tag(ocpp_tag_pk) ON DELETE CASCADE +); + +-- 2. move data from user table to user_ocpp_tag table +-- +-- IMPORTANT! this will fail if there are multiple users with the same ocpp_tag_pk (see above). +-- if you have such data, you need to resolve it before running this migration. we are not making any assumptions or +-- decisions about which user should keep the ocpp_tag (which might be a very problematic assumption), so we just fail +-- the migration. +-- +INSERT INTO user_ocpp_tag (user_pk, ocpp_tag_pk) +SELECT user_pk, ocpp_tag_pk FROM user WHERE ocpp_tag_pk IS NOT NULL; + +-- 3. now that we moved the data, drop redundant columns +ALTER TABLE user + DROP FOREIGN KEY FK_user_ocpp_tag_otpk, + DROP COLUMN ocpp_tag_pk; + +COMMIT; diff --git a/src/main/resources/webapp/WEB-INF/views/data-man/00-user-ocpp.jsp b/src/main/resources/webapp/WEB-INF/views/data-man/00-user-ocpp.jsp index ae449e468..5f5497ec7 100644 --- a/src/main/resources/webapp/WEB-INF/views/data-man/00-user-ocpp.jsp +++ b/src/main/resources/webapp/WEB-INF/views/data-man/00-user-ocpp.jsp @@ -20,11 +20,17 @@ --%> <%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %> - + - - + + -
OCPP
OCPP ID Tags
OCPP ID Tag: + + + + + +
@@ -32,4 +38,4 @@
\ No newline at end of file + diff --git a/src/main/resources/webapp/WEB-INF/views/data-man/users.jsp b/src/main/resources/webapp/WEB-INF/views/data-man/users.jsp index 5c1d1f76f..a8969fcef 100644 --- a/src/main/resources/webapp/WEB-INF/views/data-man/users.jsp +++ b/src/main/resources/webapp/WEB-INF/views/data-man/users.jsp @@ -57,7 +57,7 @@ User ID - Ocpp ID Tag + Ocpp ID Tags Name Phone E-Mail @@ -72,9 +72,9 @@ ${cr.userPk} - - ${cr.ocppIdTag} - + + ${loop.last ? '' : ', '} + ${cr.name} ${cr.phone} @@ -89,4 +89,4 @@ -<%@ include file="../00-footer.jsp" %> \ No newline at end of file +<%@ include file="../00-footer.jsp" %>