15 Commits

Author SHA1 Message Date
verboomp
e0c6a7db5a Added zip export 2026-02-23 12:18:41 +01:00
verboomp
888136e76b Added excel export 2026-02-23 10:43:06 +01:00
verboomp
38be68397a fix test 2026-02-19 14:22:46 +01:00
verboomp
5a868fe27e fix test 2026-02-19 13:55:57 +01:00
verboomp
4b8c41aba7 fix test 2026-02-19 13:25:23 +01:00
verboomp
db58ae079f unit test 2026-02-19 12:09:43 +01:00
verboomp
9b3446685a start quesitonnaire 2026-02-19 11:04:02 +01:00
verboomp
168fc986f2 Tweaking cusomter list 2026-02-10 11:13:16 +01:00
verboomp
a6216f6e81 tweaking download 2026-02-06 14:41:16 +01:00
verboomp
2955a7eb1c tweaking download 2026-02-06 13:45:35 +01:00
verboomp
7819b963f2 Fix image in pdf using the EXIF image header 2026-02-06 12:11:04 +01:00
verboomp
5e941753e2 Tweaking padding in customer list and update look and feel pdf export 2026-02-06 11:09:25 +01:00
verboomp
9c439e21ee Fix bug wrong orientation caused by EXIF 2026-02-06 10:01:45 +01:00
verboomp
b6158d933f Fix bug wrong orientation caused by EXIF 2026-02-06 09:37:03 +01:00
verboomp
bb4d7fbf68 Improved by resolving the last two fixme 2026-02-05 09:25:13 +01:00
95 changed files with 7461 additions and 312 deletions

View File

@@ -37,13 +37,32 @@
</build>
<dependencies>
<!-- EXCEL report -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
</dependency>
<!-- PDF report -->
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>3.0.5</version>
</dependency>
<!-- EXIF metadata extraction for image orientation -->
<dependency>
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>2.19.0</version>
</dependency>
<!-- Elytron secrity used for username/password login -->
<dependency>
<groupId>org.wildfly.security</groupId>
@@ -207,7 +226,7 @@
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>${version.commons-io}</version>
<version>2.21.0</version>
</dependency>
<!-- Apache POI -->
@@ -349,6 +368,14 @@
<artifactId>org.jacoco.core</artifactId>
<scope>test</scope>
</dependency>
<!-- Jakarta JSON Processing implementation for unit tests -->
<dependency>
<groupId>org.eclipse.parsson</groupId>
<artifactId>parsson</artifactId>
<version>1.1.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>

View File

@@ -20,12 +20,10 @@ import jakarta.persistence.*;
@Entity
@Table(name = "customer")
@NamedQuery(name = Customer.FIND_ALL, query = "select c from Customer c order by c.name")
@NamedQuery(name = Customer.FIND_BY_NUMBER, query = "select c from Customer c where c.customerNumber = :cutomerNumber")
public class Customer extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "customer_seq";
public static final String FIND_ALL = "Customer.findAll";
public static final String FIND_BY_NUMBER = "Customer.findByNumber";
public static final String PARAM_NUMBER = "cutomerNumber";

View File

@@ -0,0 +1,181 @@
package marketing.heyday.hartmann.fotodocumentation.core.model;
import java.util.Date;
import org.apache.commons.lang.builder.HashCodeBuilder;
import jakarta.persistence.*;
/**
*
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 19 Jan 2026
*/
@Entity
@Table(name = "questionnaire")
public class Questionnaire extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "questionnaire_seq";
@Id
@Column(name = "questionnaire_id", length = 22)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE)
@SequenceGenerator(name = SEQUENCE, sequenceName = SEQUENCE, allocationSize = 1)
private Long questionnaireId;
// username from the person that shot the picture
@Column(name = "username")
private String username;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "questionnaire_date", nullable = false)
private Date questionnaireDate;
@Basic(fetch = FetchType.LAZY)
private String comment;
@Column(name = "evaluation")
private Integer evaluation;
@Column
private String category;
@Column(name = "questions")
@Basic(fetch = FetchType.LAZY)
private String questions;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id_fk")
private QuestionnaireCustomer customer;
public Long getQuestionnaireId() {
return questionnaireId;
}
public void setQuestionnaireId(Long questionnaireId) {
this.questionnaireId = questionnaireId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public Date getQuestionnaireDate() {
return questionnaireDate;
}
public void setQuestionnaireDate(Date questionnaireDate) {
this.questionnaireDate = questionnaireDate;
}
public String getComment() {
return comment;
}
public void setComment(String comment) {
this.comment = comment;
}
public Integer getEvaluation() {
return evaluation;
}
public void setEvaluation(Integer evaluation) {
this.evaluation = evaluation;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getQuestions() {
return questions;
}
public void setQuestions(String questions) {
this.questions = questions;
}
public QuestionnaireCustomer getCustomer() {
return customer;
}
public void setCustomer(QuestionnaireCustomer customer) {
this.customer = customer;
}
@Override
public int hashCode() {
return new HashCodeBuilder().append(questionnaireId).toHashCode();
}
@Override
public boolean equals(Object obj) {
if (obj == null || this.getClass() != obj.getClass() || questionnaireId == null) {
return false;
}
return this.questionnaireId.equals(((Questionnaire) obj).getQuestionnaireId());
}
public static class Builder {
private Questionnaire instance = new Questionnaire();
public Builder(){
instance.evaluation = 0;
}
public Builder username(String username) {
instance.setUsername(username);
return this;
}
public Builder questionnaireDate(Date date) {
instance.setQuestionnaireDate(date);
return this;
}
public Builder comment(String comment) {
instance.setComment(comment);
return this;
}
public Builder category(String category) {
instance.setCategory(category);
return this;
}
public Builder questions(String questions) {
instance.setQuestions(questions);
return this;
}
public Builder evaluation(Integer evaluation) {
instance.setEvaluation(evaluation);
return this;
}
public Builder customer(QuestionnaireCustomer customer) {
instance.setCustomer(customer);
return this;
}
public Questionnaire build() {
return instance;
}
}
}

View File

@@ -0,0 +1,142 @@
package marketing.heyday.hartmann.fotodocumentation.core.model;
import java.util.HashSet;
import java.util.Set;
import org.apache.commons.lang.builder.HashCodeBuilder;
import jakarta.persistence.*;
/**
*
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 19 Jan 2026
*/
@Entity
@Table(name = "questionnaire_customer")
@NamedQuery(name = QuestionnaireCustomer.FIND_ALL, query = "select c from QuestionnaireCustomer c order by c.name")
@NamedQuery(name = QuestionnaireCustomer.FIND_BY_NUMBER, query = "select c from QuestionnaireCustomer c where c.customerNumber = :customerNumber")
public class QuestionnaireCustomer extends AbstractDateEntity {
private static final long serialVersionUID = 1L;
public static final String SEQUENCE = "questionnaire_customer_seq";
public static final String FIND_ALL = "QuestionnaireCustomer.findAll";
public static final String FIND_BY_NUMBER = "QuestionnaireCustomer.findByNumber";
public static final String PARAM_NUMBER = "customerNumber";
@Id
@Column(name = "customer_id", length = 22)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = SEQUENCE)
@SequenceGenerator(name = SEQUENCE, sequenceName = SEQUENCE, allocationSize = 1)
private Long customerId;
@Column(name = "customer_number", unique = true, nullable = false)
private String customerNumber;
@Column(name = "name", nullable = false)
private String name;
@Column(name = "city")
private String city;
@Column(name = "zip")
private String zip;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private Set<Questionnaire> questionnaires = new HashSet<>();
public Long getCustomerId() {
return customerId;
}
public void setCustomerId(Long customerId) {
this.customerId = customerId;
}
public String getCustomerNumber() {
return customerNumber;
}
public void setCustomerNumber(String customerNumber) {
this.customerNumber = customerNumber;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getZip() {
return zip;
}
public void setZip(String zip) {
this.zip = zip;
}
public Set<Questionnaire> getQuestionnaires() {
return questionnaires;
}
public void setQuestionnaires(Set<Questionnaire> questionnaires) {
this.questionnaires = questionnaires;
}
@Override
public int hashCode() {
return new HashCodeBuilder().append(customerNumber).toHashCode();
}
@Override
public boolean equals(Object obj) {
if (obj == null || this.getClass() != obj.getClass()) {
return false;
}
return this.customerNumber.equals(((QuestionnaireCustomer) obj).getCustomerNumber());
}
public static class Builder {
private QuestionnaireCustomer instance = new QuestionnaireCustomer();
public Builder customerNumber(String customerNumber) {
instance.setCustomerNumber(customerNumber);
return this;
}
public Builder name(String name) {
instance.setName(name);
return this;
}
public Builder city(String city) {
instance.setCity(city);
return this;
}
public Builder zip(String zip) {
instance.setZip(zip);
return this;
}
public QuestionnaireCustomer build() {
return instance;
}
}
}

View File

@@ -33,6 +33,14 @@ public class QueryService {
@PersistenceContext
private EntityManager eManager;
public <T> T callNamedQueryList(String namedQuery, Param... objects) {
Query query = eManager.createNamedQuery(namedQuery);
for (Param param : objects) {
query.setParameter(param.name(), param.value());
}
return (T) query.getResultList();
}
public <T> Optional<T> callNamedQuerySingleResult(String namedQuery, Param... params) {
return singleResult(eManager.createNamedQuery(namedQuery), Arrays.asList(params));
}

View File

@@ -0,0 +1,132 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import java.util.*;
import org.apache.commons.lang3.StringUtils;
import jakarta.annotation.security.PermitAll;
import jakarta.ejb.EJB;
import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless;
import jakarta.inject.Inject;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.*;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
import marketing.heyday.hartmann.fotodocumentation.core.query.QueryService;
import marketing.heyday.hartmann.fotodocumentation.core.utils.CalendarUtil;
import marketing.heyday.hartmann.fotodocumentation.core.utils.ExcelUtils;
import marketing.heyday.hartmann.fotodocumentation.core.utils.ZipExportUtils;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerValue;
/**
*
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 18 Feb 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnaireCustomerService extends AbstractService {
@EJB
private QueryService queryService;
@Inject
private ZipExportUtils zipExportUtils;
@Inject
private ExcelUtils excelUtils;
@Inject
private CalendarUtil calendarUtil;
// query = search for name, number and date
public List<QuestionnaireCustomerListValue> getAll(String queryStr, String startsWith) {
CriteriaBuilder builder = entityManager.getCriteriaBuilder();
CriteriaQuery<QuestionnaireCustomer> criteriaQuery = builder.createQuery(QuestionnaireCustomer.class);
Root<QuestionnaireCustomer> customerRoot = criteriaQuery.from(QuestionnaireCustomer.class);
criteriaQuery = criteriaQuery.select(customerRoot).distinct(true);
List<Predicate> predicates = new ArrayList<>();
if (StringUtils.isNotBlank(startsWith)) {
String param = startsWith.toLowerCase() + "%";
var pred = builder.like(builder.lower(customerRoot.get("name")), param);
predicates.add(pred);
}
if (StringUtils.isNotBlank(queryStr)) {
// check if it contains a date
Date date = calendarUtil.parse(queryStr);
if (date != null) {
Date startOfDay = calendarUtil.getStartOfDay(date);
Date endOfDay = calendarUtil.getEndOfDay(date);
Fetch<QuestionnaireCustomer, Questionnaire> picturesFetch = customerRoot.fetch("questionnaires", JoinType.LEFT);
@SuppressWarnings("unchecked")
Join<QuestionnaireCustomer, Questionnaire> pictures = (Join<QuestionnaireCustomer, Questionnaire>) picturesFetch;
var predicateDate = builder.between(pictures.get("questionnaireDate"), startOfDay, endOfDay);
predicates.add(predicateDate);
} else {
String param = "%" + StringUtils.trimToEmpty(queryStr).toLowerCase() + "%";
var predicateName = builder.like(builder.lower(customerRoot.get("name")), param);
var predicateNr = builder.like(builder.lower(customerRoot.get("customerNumber")), param);
var pred = builder.or(predicateName, predicateNr);
predicates.add(pred);
}
}
if (predicates.size() == 1) {
criteriaQuery = criteriaQuery.where(predicates.getFirst());
} else if (predicates.size() > 1) {
criteriaQuery = criteriaQuery.where(builder.and(predicates.toArray(new Predicate[0])));
}
TypedQuery<QuestionnaireCustomer> typedQuery = entityManager.createQuery(criteriaQuery);
List<QuestionnaireCustomer> customers = typedQuery.getResultList();
customers.forEach(c -> c.getQuestionnaires().size());
return customers.parallelStream().map(QuestionnaireCustomerListValue::builder).toList();
}
public QuestionnaireCustomerValue get(Long id) {
QuestionnaireCustomer customer = entityManager.find(QuestionnaireCustomer.class, id);
if (customer == null) {
return null;
}
return QuestionnaireCustomerValue.builder(customer);
}
public Optional<byte[]> getExport() {
List<QuestionnaireCustomer> customers = queryService.callNamedQueryList(QuestionnaireCustomer.FIND_ALL);
return zipExportUtils.getExport(customers);
}
public Optional<byte[]> getExport(Long id, Long questionnaireId) {
QuestionnaireCustomer customer = entityManager.find(QuestionnaireCustomer.class, id);
if (customer == null) {
return Optional.empty();
}
List<Questionnaire> questionnaires = customer.getQuestionnaires().stream().sorted((x, y) -> x.getQuestionnaireDate().compareTo(y.getQuestionnaireDate())).toList();
if (questionnaireId != null) {
Optional<Questionnaire> pictureOpt = customer.getQuestionnaires().stream().filter(p -> p.getQuestionnaireId().equals(questionnaireId)).findFirst();
questionnaires = pictureOpt.map(Arrays::asList).orElse(questionnaires);
}
return excelUtils.create(customer, questionnaires);
}
}

View File

@@ -0,0 +1,57 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import static marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer.*;
import java.util.Optional;
import jakarta.annotation.security.PermitAll;
import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
import marketing.heyday.hartmann.fotodocumentation.core.query.Param;
import marketing.heyday.hartmann.fotodocumentation.core.utils.QuestionnaireUploadJsonParser;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 18 Feb 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnairePublishService extends AbstractService {
public boolean publish(String body) {
var parserOpt = QuestionnaireUploadJsonParser.builder(body);
if (parserOpt.isEmpty()) {
return false;
}
var parser = parserOpt.get();
Optional<QuestionnaireCustomer> customerOpt = queryService.callNamedQuerySingleResult(FIND_BY_NUMBER, new Param(PARAM_NUMBER, parser.customerNumber()));
var customer = customerOpt.orElseGet(() -> new QuestionnaireCustomer.Builder().customerNumber(parser.customerNumber()).name(parser.pharmacyName())
.city(parser.city()).zip(parser.zip())
.build());
customer = entityManager.merge(customer);
var questionnaire = new Questionnaire.Builder().customer(customer).username(parser.username())
.category("")// FIXME: remove category
.comment(parser.comment())
.customer(customer)
.questionnaireDate(parser.date())
.questions(parser.questionnair())
.username(parser.username())
.build();
customer.getQuestionnaires().add(questionnaire);
entityManager.persist(questionnaire);
entityManager.flush();
return true;
}
}

View File

@@ -0,0 +1,44 @@
package marketing.heyday.hartmann.fotodocumentation.core.service;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import jakarta.annotation.security.PermitAll;
import jakarta.ejb.LocalBean;
import jakarta.ejb.Stateless;
import jakarta.persistence.EntityNotFoundException;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.utils.StorageUtils.StorageState;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 19 Jan 2026
*/
@Stateless
@LocalBean
@PermitAll
public class QuestionnaireService extends AbstractService {
private static final Log LOG = LogFactory.getLog(QuestionnaireService.class);
public StorageState delete(Long id) {
return super.delete(Questionnaire.class, id);
}
public StorageState updateEvaluationStatus(Long id, Integer value) {
try {
Questionnaire entity = entityManager.getReference(Questionnaire.class, id);
entity.setEvaluation(value);
entityManager.flush();
return StorageState.OK;
} catch (EntityNotFoundException e) {
LOG.warn("Failed to update evaluation value not found " + id, e);
ejbContext.setRollbackOnly();
return StorageState.NOT_FOUND;
}
}
}

View File

@@ -0,0 +1,193 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.CreationHelper;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
import marketing.heyday.hartmann.fotodocumentation.core.utils.QuestionnaireJsonParser.QuestionJsonObj;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 2 Feb 2026
*/
@SuppressWarnings({ "java:S818", "squid:S818", "squid:S109" })
public class ExcelUtils {
private static final Log LOG = LogFactory.getLog(ExcelUtils.class);
public Optional<byte[]> create(QuestionnaireCustomer customer, List<Questionnaire> questionnaires) {
LOG.debug("Create excel file for customer " + customer);
// TODO: implement excel export
try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); XSSFWorkbook workbook = new XSSFWorkbook()) {
for (var questionnaire : questionnaires) {
writeSheet(workbook, customer, questionnaire);
}
workbook.write(bos);
return Optional.of(bos.toByteArray());
} catch (IOException e) {
LOG.debug("Failed to export countries", e);
return Optional.empty();
}
}
private void writeSheet(XSSFWorkbook workbook, QuestionnaireCustomer customer, Questionnaire questionnaire) throws IOException {
XSSFSheet sheet = workbook.createSheet();
int rowIndex = writeCustomerData(workbook, sheet, customer, questionnaire);
writeQuestionData(sheet, questionnaire, rowIndex);
for (int i = 0; i < 10; i++) {
sheet.autoSizeColumn(i);
}
}
private int writeCustomerData(XSSFWorkbook workbook, XSSFSheet sheet, QuestionnaireCustomer customer, Questionnaire questionnaire) {
int rowIndex = 0;
XSSFRow row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(customer.getName());
row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(customer.getCustomerNumber());
row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(customer.getCity());
row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(customer.getZip());
row = sheet.createRow(rowIndex++);
CreationHelper createHelper = workbook.getCreationHelper();
short format = createHelper.createDataFormat().getFormat("dd.MM.yyyy h:mm");
CellStyle cellStyle = workbook.createCellStyle();
cellStyle.setDataFormat(format);
var dateCell = row.createCell(0);
dateCell.setCellStyle(cellStyle);
dateCell.setCellValue(questionnaire.getQuestionnaireDate());
rowIndex++;
rowIndex++;
return rowIndex;
}
private void writeQuestionData(XSSFSheet sheet, Questionnaire questionnaire, int rowIndex) throws IOException {
QuestionnaireJsonParser parser = new QuestionnaireJsonParser();
var success = parser.parse(questionnaire.getQuestions());
if (!success) {
throw new IOException("Failed to parse json");
}
var questions = parser.getQuestions();
for (var question : questions) {
XSSFRow row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(question.title());
switch (question.type()) {
case "singleChoice":
writeSingle(row, question);
break;
case "multiplChoice":
int amount1 = writeMultiple(sheet, row, question);
rowIndex = rowIndex + amount1;
break;
case "number":
writeNumber(row, question);
break;
case "matrix":
int amount2 = writeMatrix(sheet, row, question);
rowIndex = rowIndex + amount2;
break;
case "freeText":
writeFreeText(row, question);
break;
default:
break;
}
rowIndex++;
}
}
private void writeSingle(XSSFRow row, QuestionJsonObj question) {
row.createCell(1).setCellValue(question.getSingleAnswer());
}
private int writeMultiple(XSSFSheet sheet, XSSFRow row, QuestionJsonObj question) {
var answers = question.getMultiAnswer();
int count = 0;
for (var answer : answers) {
if (count == 0) {
row.createCell(1).setCellValue(answer);
} else {
sheet.createRow(row.getRowNum() + count).createCell(1).setCellValue(answer);
}
count++;
}
return count - 1;
}
private void writeNumber(XSSFRow row, QuestionJsonObj question) {
var cell = row.createCell(1);
cell.setCellType(CellType.NUMERIC);
cell.setCellValue(question.getNumberAnswer());
}
private void writeFreeText(XSSFRow row, QuestionJsonObj question) {
row.createCell(1).setCellValue(question.getFreeText());
}
private int writeMatrix(XSSFSheet sheet, XSSFRow row, QuestionJsonObj question) {
var questions = question.getMatrixAnswer();
int count = 2;
var answerOpts = questions.getFirst().answers();
answerOpts.sort((i, j) -> i.answer().compareToIgnoreCase(j.answer()));
var headerRow = sheet.createRow(row.getRowNum() + count);
// create header
for (int i = 0; i < answerOpts.size(); i++) {
var answer = answerOpts.get(i);
headerRow.createCell(i + 1).setCellValue(answer.answer());
}
count++;
for (var subQuestion : questions) {
var questionRow = sheet.createRow(row.getRowNum() + count);
questionRow.createCell(0).setCellValue(subQuestion.title());
for (var answer : subQuestion.answers()) {
if (!answer.selected()) {
continue;
}
int index = 0;
for (int i = 0; i < answerOpts.size(); i++) {
if (answer.answer().equalsIgnoreCase(answerOpts.get(i).answer())) {
index = i;
}
}
var cell = questionRow.createCell(1 + index);
cell.setCellType(CellType.BOOLEAN);
cell.setCellValue(answer.selected());
}
count++;
}
return count;
}
}

View File

@@ -0,0 +1,123 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import javax.imageio.ImageIO;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.MetadataException;
import com.drew.metadata.exif.ExifIFD0Directory;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 6 Feb 2026
*/
public interface ImageHandler {
static final Log LOG = LogFactory.getLog(ImageHandler.class);
/**
* Reads image bytes and returns a BufferedImage with correct EXIF orientation applied.
*/
default BufferedImage readImageWithCorrectOrientation(byte[] imageBytes) throws IOException {
int orientation = getExifOrientation(imageBytes);
BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes));
if (image == null) {
throw new IOException("Failed to read image from byte array");
}
return applyOrientation(image, orientation);
}
default int getExifOrientation(byte[] imageBytes) {
try {
Metadata metadata = ImageMetadataReader.readMetadata(new ByteArrayInputStream(imageBytes));
ExifIFD0Directory directory = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (directory != null && directory.containsTag(ExifIFD0Directory.TAG_ORIENTATION)) {
return directory.getInt(ExifIFD0Directory.TAG_ORIENTATION);
}
} catch (ImageProcessingException | IOException | MetadataException e) {
LOG.debug("Could not read EXIF orientation: " + e.getMessage());
}
return 1;
}
default BufferedImage applyOrientation(BufferedImage image, int orientation) {
int width = image.getWidth();
int height = image.getHeight();
AffineTransform transform = new AffineTransform();
int newWidth = width;
int newHeight = height;
switch (orientation) {
case 1 -> {
return image;
}
case 2 -> {
transform.scale(-1.0, 1.0);
transform.translate(-width, 0);
}
case 3 -> {
transform.translate(width, height);
transform.rotate(Math.PI);
}
case 4 -> {
transform.scale(1.0, -1.0);
transform.translate(0, -height);
}
case 5 -> {
newWidth = height;
newHeight = width;
transform.rotate(-Math.PI / 2);
transform.scale(-1.0, 1.0);
}
case 6 -> {
newWidth = height;
newHeight = width;
transform.translate(height, 0);
transform.rotate(Math.PI / 2);
}
case 7 -> {
newWidth = height;
newHeight = width;
transform.scale(-1.0, 1.0);
transform.translate(-height, 0);
transform.translate(0, width);
transform.rotate(3 * Math.PI / 2);
}
case 8 -> {
newWidth = height;
newHeight = width;
transform.translate(0, width);
transform.rotate(-Math.PI / 2);
}
default -> {
return image;
}
}
BufferedImage rotated = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = rotated.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.drawImage(image, transform, null);
g2d.dispose();
return rotated;
}
}

View File

@@ -18,16 +18,16 @@ import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
*
* created: 2 Feb 2026
*/
public class ImageUtil {
public class ImageUtil implements ImageHandler {
private static final Log LOG = LogFactory.getLog(ImageUtil.class);
private static final int NORMAL_MAX_WIDTH = 1200;
@@ -44,10 +44,10 @@ public class ImageUtil {
public byte[] getImage(String base64, int size) {
byte[] original = Base64.getDecoder().decode(base64);
return switch (size) {
case 1 -> original;
case 1 -> applyExifOrientation(original);
case 2 -> normal(original);
case 3 -> thumbnail(original);
default -> original;
default -> applyExifOrientation(original);
};
}
@@ -61,16 +61,27 @@ public class ImageUtil {
private byte[] resize(byte[] original, int maxWidth, float quality) {
try {
int orientation = getExifOrientation(original);
BufferedImage image = ImageIO.read(new ByteArrayInputStream(original));
if (image == null) {
LOG.error("Failed to read image from byte array");
return original;
}
// For rotated images (orientation 5, 6, 7, 8), width and height are swapped
int effectiveWidth = (orientation >= 5 && orientation <= 8) ? image.getHeight() : image.getWidth();
// If no resize needed and no orientation fix needed, return original
if (effectiveWidth <= maxWidth && orientation == 1) {
return original;
}
image = applyOrientation(image, orientation);
int originalWidth = image.getWidth();
if (originalWidth <= maxWidth) {
return original;
return writeJpeg(image, quality);
}
double scale = (double) maxWidth / originalWidth;
@@ -92,6 +103,26 @@ public class ImageUtil {
}
}
private byte[] applyExifOrientation(byte[] original) {
try {
int orientation = getExifOrientation(original);
if (orientation == 1) {
return original;
}
BufferedImage image = ImageIO.read(new ByteArrayInputStream(original));
if (image == null) {
return original;
}
BufferedImage rotated = applyOrientation(image, orientation);
return writeJpeg(rotated, 1.0F);
} catch (IOException e) {
LOG.error("Failed to apply EXIF orientation", e);
return original;
}
}
private byte[] writeJpeg(BufferedImage image, float quality) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();

View File

@@ -0,0 +1,57 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Set;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.ValidationMessage;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 20 Feb 2026
*/
public class JsonSchemaValidator {
private static final Log LOG = LogFactory.getLog(JsonSchemaValidator.class);
public ValidationReply validate(String schemaPath, String jsonData) {
try {
JsonSchema schema = getJsonSchema(schemaPath);
JsonNode node = getJsonNode(jsonData);
Set<ValidationMessage> errors = schema.validate(node);
if (!errors.isEmpty()) {
LOG.error("Failed to validate json to schema " + schemaPath);
errors.forEach(LOG::error);
}
return new ValidationReply(errors.isEmpty(), errors);
} catch (IOException e) {
LOG.error(e.getMessage(), e);
return new ValidationReply(false, new HashSet<>());
}
}
protected JsonSchema getJsonSchema(String name) throws IOException {
JsonSchemaFactory factory = new JsonSchemaFactory();
try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(name);) {
return factory.getSchema(input);
}
}
protected JsonNode getJsonNode(String content) throws IOException {
return new ObjectMapper().readTree(content);
}
}

View File

@@ -1,6 +1,7 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
@@ -19,6 +20,7 @@ import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import marketing.heyday.hartmann.fotodocumentation.core.model.Customer;
@@ -34,7 +36,7 @@ import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
* created: 2 Feb 2026
*/
@SuppressWarnings({ "java:S818", "squid:S818", "squid:S109" })
public class PdfUtils {
public class PdfUtils implements ImageHandler {
private static final Log LOG = LogFactory.getLog(PdfUtils.class);
private static final String FONT_PANTON_REGULAR = "fonts/Panton-Regular.ttf";
@@ -70,7 +72,8 @@ public class PdfUtils {
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
float contentWidth = pageWidth - 2 * PAGE_MARGIN;
float halfWidth = contentWidth / 2F;
float imageWidth = contentWidth * 0.75F;
float metadataWidth = contentWidth * 0.25F;
try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
float yPosition = pageHeight - 50F;
@@ -87,16 +90,17 @@ public class PdfUtils {
firstPage = false;
}
// Left side: image (50% of content width)
// Left side: image (75% of content width)
float imageX = PAGE_MARGIN;
float imageY = yPosition;
float imageMaxWidth = halfWidth - 10F;
float imageMaxWidth = imageWidth - 10F;
float imageMaxHeight = pageHeight - 2 * PAGE_MARGIN - 40F;
if (picture.getImage() != null) {
try {
byte[] imageBytes = Base64.getDecoder().decode(picture.getImage());
PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, imageBytes, "picture");
BufferedImage correctedImage = readImageWithCorrectOrientation(imageBytes);
PDImageXObject pdImage = LosslessFactory.createFromImage(document, correctedImage);
float scale = Math.min(imageMaxWidth / pdImage.getWidth(), imageMaxHeight / pdImage.getHeight());
float drawWidth = pdImage.getWidth() * scale;
@@ -108,30 +112,25 @@ public class PdfUtils {
}
}
// Right side: metadata (top-aligned with image)
float rightX = PAGE_MARGIN + halfWidth + 10F;
float rightY = imageY - 32F;
// Right side: metadata (25% of content width, top-aligned with image)
float rightX = PAGE_MARGIN + imageWidth + 10F;
float rightY = imageY - 10F;
// Date (no label, bold, size 44)
// Date (bold, size 10 - matching labels)
String dateStr = picture.getPictureDate() != null ? (DATE_FORMAT.format(picture.getPictureDate()) + " UHR") : "";
cs.setFont(fontBold, 32);
cs.setFont(fontBold, 10);
cs.setNonStrokingColor(COLOR_DATE);
cs.beginText();
cs.newLineAtOffset(rightX, rightY);
cs.showText(dateStr);
cs.endText();
rightY -= 54F;
rightY -= 24F;
// Customer number
float kundenNummerY = rightY;
rightY = drawLabel(cs, fontBold, "KUNDENNUMMER", rightX, rightY);
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCustomerNumber()), rightX, rightY);
rightY -= 10F;
// Evaluation card with circles
float circlesX = rightX + 140F;
drawEvaluationCard(cs, fontBold, circlesX, kundenNummerY, picture.getEvaluation());
// ZIP
rightY = drawLabel(cs, fontBold, "PLZ", rightX, rightY);
rightY = drawValue(cs, fontRegular, nullSafe(customer.getZip()), rightX, rightY);
@@ -142,9 +141,27 @@ public class PdfUtils {
rightY = drawValue(cs, fontRegular, nullSafe(customer.getCity()), rightX, rightY);
rightY -= 10F;
// Evaluation card with circles
float evaluationY = rightY;
drawEvaluationCard(cs, fontBold, rightX, evaluationY, picture.getEvaluation());
rightY -= 80F;
// Comment
rightY = drawLabel(cs, fontBold, "KOMMENTAR", rightX, rightY);
drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, halfWidth - 20f);
String remainingComment = drawWrappedText(cs, fontRegular, nullSafe(picture.getComment()), rightX, rightY, metadataWidth - 20f, 50F);
// Continue comment on additional pages if needed
while (remainingComment != null && !remainingComment.isEmpty()) {
PDPage continuationPage = new PDPage(new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()));
document.addPage(continuationPage);
try (PDPageContentStream continuationCs = new PDPageContentStream(document, continuationPage)) {
float continuationY = continuationPage.getMediaBox().getHeight() - PAGE_MARGIN;
float continuationWidth = continuationPage.getMediaBox().getWidth() - 2 * PAGE_MARGIN;
continuationY = drawLabel(continuationCs, fontBold, "KOMMENTAR (FORTSETZUNG)", PAGE_MARGIN, continuationY);
remainingComment = drawWrappedText(continuationCs, fontRegular, remainingComment, PAGE_MARGIN, continuationY, continuationWidth, 50F);
}
}
}
}
@@ -189,9 +206,9 @@ public class PdfUtils {
return y - 14F;
}
private void drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth) throws IOException {
private String drawWrappedText(PDPageContentStream cs, PDFont font, String text, float x, float y, float maxWidth, float minY) throws IOException {
if (text == null || text.isEmpty()) {
return;
return null;
}
cs.setFont(font, 10);
cs.setNonStrokingColor(COLOR_TEXT_GRAY);
@@ -199,11 +216,23 @@ public class PdfUtils {
String[] words = text.split("\\s+");
StringBuilder line = new StringBuilder();
float currentY = y;
int wordIndex = 0;
for (String word : words) {
for (wordIndex = 0; wordIndex < words.length; wordIndex++) {
String word = words[wordIndex];
String testLine = line.isEmpty() ? word : (line + " " + word);
float textWidth = font.getStringWidth(testLine) / 1000F * 10F;
if (textWidth > maxWidth && !line.isEmpty()) {
// Check if we have room for this line
if (currentY < minY) {
// Return remaining text starting from current word
StringBuilder remaining = new StringBuilder(line);
for (int i = wordIndex; i < words.length; i++) {
if (!remaining.isEmpty()) remaining.append(" ");
remaining.append(words[i]);
}
return remaining.toString();
}
cs.beginText();
cs.newLineAtOffset(x, currentY);
cs.showText(line.toString());
@@ -215,45 +244,36 @@ public class PdfUtils {
}
}
if (!line.isEmpty()) {
if (currentY < minY) {
return line.toString();
}
cs.beginText();
cs.newLineAtOffset(x, currentY);
cs.showText(line.toString());
cs.endText();
}
return null;
}
private void drawEvaluationCard(PDPageContentStream cs, PDFont fontBold, float x, float y, Integer evaluation) throws IOException {
int eval = evaluation != null ? evaluation : 0;
Color[] colors = { COLOR_GREEN, COLOR_YELLOW, COLOR_RED };
float cardPadding = 10F;
float cardWidth = 2 * CIRCLE_SPACING + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding;
float labelHeight = 14F;
float cardHeight = labelHeight + 2 * HIGHLIGHT_RADIUS + 2 * cardPadding + 4F;
float cardX = x - HIGHLIGHT_RADIUS - cardPadding;
float cardY = y - cardHeight + cardPadding;
// Draw card background (rounded rectangle)
cs.setStrokingColor(new Color(0xDD, 0xDD, 0xDD));
cs.setNonStrokingColor(new Color(0xF8, 0xF8, 0xF8));
cs.setLineWidth(1f);
drawRoundedRect(cs, cardX, cardY, cardWidth, cardHeight, 6F);
cs.fillAndStroke();
// Draw "BEWERTUNG" label above circles
float labelX = x;
float labelY = y - labelHeight;
cs.setFont(fontBold, 9);
cs.setFont(fontBold, 10);
cs.setNonStrokingColor(COLOR_CUSTOMER_NAME);
cs.beginText();
cs.newLineAtOffset(labelX, labelY);
cs.newLineAtOffset(x, labelY);
cs.showText("BEWERTUNG");
cs.endText();
// Draw circles below the label
float circleY = labelY - cardPadding - HIGHLIGHT_RADIUS - 2F;
float circleY = labelY - HIGHLIGHT_RADIUS - 10F;
for (int i = 0; i < 3; i++) {
float cx = x + i * CIRCLE_SPACING;
float cx = x + CIRCLE_RADIUS + i * CIRCLE_SPACING;
// Highlight circle if this matches the evaluation (1=green, 2=yellow, 3=red)
if (eval == i + 1) {
@@ -269,20 +289,7 @@ public class PdfUtils {
cs.fill();
}
}
private void drawRoundedRect(PDPageContentStream cs, float x, float y, float w, float h, float r) throws IOException {
cs.moveTo(x + r, y);
cs.lineTo(x + w - r, y);
cs.curveTo(x + w, y, x + w, y, x + w, y + r);
cs.lineTo(x + w, y + h - r);
cs.curveTo(x + w, y + h, x + w, y + h, x + w - r, y + h);
cs.lineTo(x + r, y + h);
cs.curveTo(x, y + h, x, y + h, x, y + h - r);
cs.lineTo(x, y + r);
cs.curveTo(x, y, x, y, x + r, y);
cs.closePath();
}
private void drawCircle(PDPageContentStream cs, float cx, float cy, float r) throws IOException {
float k = 0.5523f; // Bezier approximation for circle
cs.moveTo(cx - r, cy);

View File

@@ -0,0 +1,170 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.function.Supplier;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import jakarta.json.*;
import jakarta.json.stream.JsonParsingException;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 20 Feb 2026
*/
public class QuestionnaireJsonParser {
private static final Log LOG = LogFactory.getLog(QuestionnaireJsonParser.class);
private final List<QuestionJsonObj> questions = new ArrayList<>();
public List<QuestionJsonObj> getQuestions() {
return questions;
}
public boolean parse(String body) {
questions.clear();
if (body == null) {
return false;
}
try (var stringReader = new StringReader(body); JsonReader reader = Json.createReader(stringReader);) {
JsonArray root = reader.readArray();
for (var object : root) {
var question = object.asJsonObject();
String id = question.getString("id");
String title = question.getString("title");
int order = question.getInt("order");
String type = question.getString("type");
String data = toData(question.getJsonObject("data"));
questions.add(new QuestionJsonObj(id, title, order, type, data));
}
return true;
} catch (IOException | JsonParsingException | NullPointerException ioe) {
LOG.warn("Failed to parse json " + ioe.getMessage(), ioe);
return false;
}
}
private String toData(JsonObject obj) throws IOException {
try (StringWriter writer = new StringWriter(); JsonWriter jsonWriter = Json.createWriter(writer);) {
jsonWriter.writeObject(obj);
jsonWriter.close();
return writer.toString();
}
}
public record QuestionJsonObj(String id, String title, int order, String type, String data) {
public List<MatrixQuestion> getMatrixAnswer() {
return getValue("questions", () -> null, (questions) -> {
var retVal = new ArrayList<MatrixQuestion>();
for (var question : questions) {
var questionObj = question.asJsonObject();
var id = questionObj.getString("id");
var title = questionObj.getString("title");
var order = questionObj.getInt("order");
var answerList = new ArrayList<MatrixAnswer>();
var answers = questionObj.getJsonArray("answers");
for (var answer : answers) {
var answerObj = answer.asJsonObject();
var answerId = answerObj.getString("id");
var answerStr = answerObj.getString("answer");
var selected = answerObj.getBoolean("selected");
answerList.add(new MatrixAnswer(answerId, answerStr, selected));
}
retVal.add(new MatrixQuestion(id, title, order, answerList));
}
return retVal;
});
}
public Integer getNumberAnswer() {
return getValue(() -> null, (answers) -> {
for (var answer : answers) {
var answerObj = answer.asJsonObject();
if (answerObj.getBoolean("selected")) {
return answerObj.getInt("answer");
}
}
return null;
});
}
public String getSingleAnswer() {
return getValue(() -> "", (answers) -> {
for (var answer : answers) {
var answerObj = answer.asJsonObject();
if (answerObj.getBoolean("selected")) {
return answerObj.getString("answer");
}
}
return "";
});
}
public List<String> getMultiAnswer() {
return getValue(() -> List.of(), (answers) -> {
List<String> retVal = new ArrayList<>();
for (var answer : answers) {
var answerObj = answer.asJsonObject();
if (answerObj.getBoolean("selected")) {
retVal.add(answerObj.getString("answer"));
}
}
return retVal;
});
}
public String getFreeText() {
return getValue(() -> "", (answers) -> answers.getFirst().asJsonObject().getString("answer"));
}
private <T> T getValue(Supplier<T> emptyValue, Function<JsonArray, T> function) {
return getValue("answers", emptyValue, function);
}
private <T> T getValue(String element, Supplier<T> emptyValue, Function<JsonArray, T> function) {
try (var stringReader = new StringReader(data); JsonReader reader = Json.createReader(stringReader);) {
JsonObject root = reader.readObject();
JsonArray answers = root.getJsonArray(element);
if (answers.isEmpty()) {
return emptyValue.get();
}
return function.apply(answers);
} catch (JsonParsingException | NullPointerException ioe) {
LOG.warn("Failed to parse json " + ioe.getMessage(), ioe);
return emptyValue.get();
}
}
}
public record MatrixQuestion(String id, String title, int order, List<MatrixAnswer> answers) {
}
public record MatrixAnswer(String id, String answer, boolean selected) {
}
}

View File

@@ -0,0 +1,74 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.IOException;
import java.io.StringReader;
import java.io.StringWriter;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import jakarta.json.*;
import jakarta.json.stream.JsonParsingException;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 20 Feb 2026
*/
public record QuestionnaireUploadJsonParser(
String username,
String pharmacyName,
String customerNumber,
Date date,
String comment,
String city,
String zip,
String questionnair) {
private static final Log LOG = LogFactory.getLog(QuestionnaireUploadJsonParser.class);
public static Optional<QuestionnaireUploadJsonParser> builder(String body) {
if (body == null) {
return Optional.empty();
}
try (var stringReader = new StringReader(body); JsonReader reader = Json.createReader(stringReader);) {
JsonObject root = reader.readObject();
String username = root.getString("username");
String pharmacyName = root.getString("pharmacyName");
String customerNumber = root.getString("customerNumber");
String dateStr = root.getString("date");
var dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX");
Date date = dateFormat.parse(dateStr);
String comment = root.getString("comment");
String city = root.getString("city");
String zip = root.getString("zip");
JsonArray questionnaireArr = root.getJsonArray("questionnaire");
String questionnaire = "";
try (StringWriter writer = new StringWriter(); JsonWriter jsonWriter = Json.createWriter(writer);) {
jsonWriter.writeArray(questionnaireArr);
jsonWriter.close();
questionnaire = writer.toString();
}
return Optional.of(new QuestionnaireUploadJsonParser(username, pharmacyName, customerNumber, date, comment, city, zip, questionnaire));
} catch (IOException | ParseException | JsonParsingException | NullPointerException ioe) {
LOG.warn("Failed to parse json " + ioe.getMessage(), ioe );
return Optional.empty();
}
}
}

View File

@@ -1,4 +1,4 @@
package marketing.heyday.hartmann.fotodocumentation.rest.jackson;
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.util.Set;

View File

@@ -0,0 +1,55 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import jakarta.inject.Inject;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 23 Feb 2026
*/
public class ZipExportUtils {
private static final Log LOG = LogFactory.getLog(ZipExportUtils.class);
@Inject
private ExcelUtils excelUtils;
public Optional<byte[]> getExport(List<QuestionnaireCustomer> customers) {
if (customers.isEmpty()) {
return Optional.empty();
}
try (ZipUtils zipUtils = new ZipUtils()) {
boolean hasContent = false;
for (var customer : customers) {
List<Questionnaire> questionnaires = customer.getQuestionnaires().stream().sorted((x, y) -> x.getQuestionnaireDate().compareTo(y.getQuestionnaireDate())).toList();
var file = excelUtils.create(customer, questionnaires);
if (file.isPresent()) {
zipUtils.addFile(customer.getName() + ".xlsx", file.get());
hasContent = true;
}
}
if (!hasContent) {
return Optional.empty();
}
return Optional.of(zipUtils.create());
} catch (IOException ioe) {
LOG.error("Failed to create zip file " + ioe.getMessage(), ioe);
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,41 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 23 Feb 2026
*/
public class ZipUtils implements AutoCloseable {
private ByteArrayOutputStream baos = new ByteArrayOutputStream();
private ZipOutputStream zos = new ZipOutputStream(baos);
public void addFile(String name, byte[] file) throws IOException {
ZipEntry entry = new ZipEntry(name);
zos.putNextEntry(entry);
zos.write(file);
zos.closeEntry();
}
public byte[] create() throws IOException {
zos.flush();
zos.close();
return baos.toByteArray();
}
@Override
public void close() throws IOException {
zos.close();
baos.close();
}
}

View File

@@ -0,0 +1,111 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import static marketing.heyday.hartmann.fotodocumentation.rest.jackson.ApplicationConfigApi.JSON_OUT;
import java.io.OutputStream;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.jboss.resteasy.annotations.GZIP;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.core.StreamingOutput;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnaireCustomerService;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerListValue;
import marketing.heyday.hartmann.fotodocumentation.rest.vo.QuestionnaireCustomerValue;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 21 Jan 2026
*/
@RequestScoped
@Path("questionnairecustomer")
public class QuestionnaireCustomerResource {
private static final Log LOG = LogFactory.getLog(QuestionnaireCustomerResource.class);
@EJB
private QuestionnaireCustomerService questionnaireCustomerService;
@GZIP
@GET
@Path("")
@Produces(JSON_OUT)
@Operation(summary = "Get customer list")
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer list", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = QuestionnaireCustomerListValue.class))))
public Response doGetCustomerList(@QueryParam("query") String query, @QueryParam("startsWith") String startsWith) {
LOG.debug("Query customers for query " + query + " startsWith: " + startsWith);
var retVal = questionnaireCustomerService.getAll(query, startsWith);
return Response.ok().entity(retVal).build();
}
@GZIP
@GET
@Path("{id}")
@Produces(JSON_OUT)
@Operation(summary = "Get customer value")
@ApiResponse(responseCode = "200", description = "Successfully retrieved customer value", content = @Content(mediaType = JSON_OUT, array = @ArraySchema(schema = @Schema(implementation = QuestionnaireCustomerValue.class))))
public Response doGetDetailCustomer(@PathParam("id") Long id) {
LOG.debug("Get Customer details for id " + id);
var retVal = questionnaireCustomerService.get(id);
return Response.ok().entity(retVal).build();
}
@GZIP
@GET
@Path("exportall")
@Produces("application/zip")
@Operation(summary = "Get Export")
@ApiResponse(responseCode = "200", description = "Successfully retrieved export")
public Response doExport() {
LOG.debug("Create export for customer ");
Optional<byte[]> pdfOpt = questionnaireCustomerService.getExport();
if (pdfOpt.isEmpty()) {
return Response.status(Status.NOT_FOUND).build();
}
StreamingOutput streamingOutput = (OutputStream output) -> {
LOG.debug("Start writing content to OutputStream available bytes");
output.write(pdfOpt.get());
};
return Response.status(Status.OK).entity(streamingOutput).build();
}
@GZIP
@GET
@Path("export/{id}")
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
@Operation(summary = "Get Export")
@ApiResponse(responseCode = "200", description = "Successfully retrieved export")
public Response doExport(@PathParam("id") Long id, @QueryParam("questionnaire") Long questionnaireId) {
LOG.debug("Create export for customer " + id + " with optional param " + questionnaireId);
Optional<byte[]> pdfOpt = questionnaireCustomerService.getExport(id, questionnaireId);
if (pdfOpt.isEmpty()) {
return Response.status(Status.NOT_FOUND).build();
}
StreamingOutput streamingOutput = (OutputStream output) -> {
LOG.debug("Start writing content to OutputStream available bytes");
output.write(pdfOpt.get());
};
return Response.status(Status.OK).entity(streamingOutput).build();
}
}

View File

@@ -0,0 +1,67 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
import org.jboss.resteasy.annotations.GZIP;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnairePublishService;
import marketing.heyday.hartmann.fotodocumentation.core.utils.JsonSchemaValidator;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 18 Feb 2026
*/
@RequestScoped
@Path("questionnaire-publish")
public class QuestionnairePublishResource {
@EJB
private QuestionnairePublishService questionnairePublishService;
@Inject
private JsonSchemaValidator jsonSchemaValidator;
@GZIP
@POST
@Path("")
@Consumes(MediaType.APPLICATION_JSON)
@Operation(summary = "Add questionnaire to database")
@ApiResponse(responseCode = "200", description = "Add successfull")
public Response doAddQuestionnaire(InputStream inputStream) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String body = reader.lines().collect(Collectors.joining("\n"));
var reply = jsonSchemaValidator.validate("schema/questionnaire_publish.json", body);
if (!reply.success()) {
return Response.status(Status.BAD_REQUEST).entity(reply.errors()).build();
}
questionnairePublishService.publish(body);
return Response.ok().build();
} catch (IOException e) {
return Response.status(Status.BAD_REQUEST).build();
}
}
}

View File

@@ -0,0 +1,70 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import jakarta.ejb.EJB;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.ResponseBuilder;
import jakarta.ws.rs.core.Response.Status;
import marketing.heyday.hartmann.fotodocumentation.core.service.QuestionnaireService;
import marketing.heyday.hartmann.fotodocumentation.core.utils.EvaluationUtil;
import marketing.heyday.hartmann.fotodocumentation.core.utils.StorageUtils.StorageState;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 21 Jan 2026
*/
@RequestScoped
@Path("questionnaire")
public class QuestionnaireResource {
private static final Log LOG = LogFactory.getLog(QuestionnaireResource.class);
@EJB
private QuestionnaireService questionnaireService;
@Inject
private EvaluationUtil evaluationUtil;
@DELETE
@Path("{id}")
@Operation(summary = "Delete questionnaire from database")
@ApiResponse(responseCode = "200", description = "Task successfully deleted")
@ApiResponse(responseCode = "404", description = "Task not found")
@ApiResponse(responseCode = "403", description = "Insufficient permissions")
public Response doDelete(@PathParam("id") Long id) {
LOG.debug("Delete questionnaire with id " + id);
var state = questionnaireService.delete(id);
return deleteResponse(state).build();
}
@PUT
@Path("evaluation/{id}")
@Operation(summary = "Update evaluation for questionnaire data to database")
@ApiResponse(responseCode = "200", description = "Task successfully updated")
public Response doUpdateEvaluation(@PathParam("id") Long id, @QueryParam("evaluation") Integer value) {
if (evaluationUtil.isInValid(value)) {
return Response.status(Status.BAD_REQUEST).build();
}
StorageState state = questionnaireService.updateEvaluationStatus(id, value);
return deleteResponse(state).build();
}
protected ResponseBuilder deleteResponse(StorageState state) {
return switch (state) {
case OK -> Response.status(Status.OK);
case NOT_FOUND -> Response.status(Status.NOT_FOUND);
default -> Response.status(Status.INTERNAL_SERVER_ERROR);
};
}
}

View File

@@ -46,6 +46,10 @@ public class ApplicationConfigApi extends Application {
retVal.add(CustomerPictureResource.class);
retVal.add(CustomerResource.class);
retVal.add(PictureResource.class);
retVal.add(QuestionnairePublishResource.class);
retVal.add(QuestionnaireCustomerResource.class);
retVal.add(QuestionnaireResource.class);
LOG.info("returning rest api classes " + retVal);
return retVal;
}

View File

@@ -5,19 +5,10 @@ import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.ValidationMessage;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.WebApplicationException;
@@ -27,6 +18,8 @@ import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.Response.Status;
import jakarta.ws.rs.ext.MessageBodyReader;
import jakarta.ws.rs.ext.Provider;
import marketing.heyday.hartmann.fotodocumentation.core.utils.JsonSchemaValidator;
import marketing.heyday.hartmann.fotodocumentation.core.utils.ValidationReply;
/**
*
@@ -44,7 +37,6 @@ import jakarta.ws.rs.ext.Provider;
MediaType.APPLICATION_JSON, "application/json; charset=utf-8",
})
public class ValidatedMessageBodyReader implements MessageBodyReader<SchemaValidated> {
private static final Log LOG = LogFactory.getLog(ValidatedMessageBodyReader.class);
/* (non-Javadoc)
* @see jakarta.ws.rs.ext.MessageBodyReader#isReadable(java.lang.Class, java.lang.reflect.Type, java.lang.annotation.Annotation[], jakarta.ws.rs.core.MediaType)
@@ -85,30 +77,7 @@ public class ValidatedMessageBodyReader implements MessageBodyReader<SchemaValid
*/
private ValidationReply validate(JsonSchemaValidate jsonSchema, String jsonData) {
String schemaPath = jsonSchema.value();
try {
JsonSchema schema = getJsonSchema(schemaPath);
JsonNode node = getJsonNode(jsonData);
Set<ValidationMessage> errors = schema.validate(node);
if (!errors.isEmpty()) {
LOG.error("Failed to validate json to schema " + schemaPath);
errors.forEach(LOG::error);
}
return new ValidationReply(errors.isEmpty(), errors);
} catch (IOException e) {
LOG.error(e.getMessage(), e);
return new ValidationReply(false, new HashSet<>());
}
}
protected JsonSchema getJsonSchema(String name) throws IOException {
JsonSchemaFactory factory = new JsonSchemaFactory();
try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream(name);) {
return factory.getSchema(input);
}
}
protected JsonNode getJsonNode(String content) throws IOException {
return new ObjectMapper().readTree(content);
return new JsonSchemaValidator().validate(schemaPath, jsonData);
}
private String read(InputStream input) throws IOException {

View File

@@ -17,13 +17,13 @@ import marketing.heyday.hartmann.fotodocumentation.core.model.Picture;
*/
@Schema(name = "CustomerList")
public record CustomerListValue(Long id, String name, String customerNumber, Date lastUpdateDate) {
public record CustomerListValue(Long id, String name, String customerNumber, String zip, String city, Date lastUpdateDate) {
public static CustomerListValue builder(Customer customer) {
if (customer == null) {
return null;
}
Date date = customer.getPictures().stream().map(Picture::getPictureDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null);
return new CustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), date);
return new CustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getZip(), customer.getCity(), date);
}
}

View File

@@ -0,0 +1,29 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 19 Jan 2026
*/
@Schema(name = "QuestionnaireCustomerList")
public record QuestionnaireCustomerListValue(Long id, String name, String customerNumber, String zip, String city, Date lastUpdateDate) {
public static QuestionnaireCustomerListValue builder(QuestionnaireCustomer customer) {
if (customer == null) {
return null;
}
Date date = customer.getQuestionnaires().stream().map(Questionnaire::getQuestionnaireDate).sorted((p1, p2) -> p2.compareTo(p1)).findFirst().orElse(null);
return new QuestionnaireCustomerListValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getZip(), customer.getCity(), date);
}
}

View File

@@ -0,0 +1,27 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 22 Jan 2026
*/
@Schema(name = "QuestionnaireCustomer")
public record QuestionnaireCustomerValue(Long id, String name, String customerNumber, String city, String zip, List<QuestionnaireValue> questionnaires) {
public static QuestionnaireCustomerValue builder(QuestionnaireCustomer customer) {
if (customer == null) {
return null;
}
List<QuestionnaireValue> questionnaires = customer.getQuestionnaires().parallelStream().map(QuestionnaireValue::builder).filter(p -> p != null).toList();
return new QuestionnaireCustomerValue(customer.getCustomerId(), customer.getName(), customer.getCustomerNumber(), customer.getCity(), customer.getZip(), questionnaires);
}
}

View File

@@ -0,0 +1,21 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.rest.jackson.SchemaValidated;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 18 Feb 2026
*/
@Schema(name = "QuestionnairePublish")
public record QuestionnairePublishValue(String username, String pharmacyName, String customerNumber, Date date, String zip, String city, String comment, String category, String base64String) implements SchemaValidated {
}

View File

@@ -0,0 +1,28 @@
package marketing.heyday.hartmann.fotodocumentation.rest.vo;
import java.util.Date;
import io.swagger.v3.oas.annotations.media.Schema;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 22 Jan 2026
*/
@Schema(name = "Questionnaire")
public record QuestionnaireValue(Long id, String comment, String category, Date questionnaireDate, String username, Integer evaluation) {
public static QuestionnaireValue builder(Questionnaire questionnaire) {
if (questionnaire == null) {
return null;
}
return new QuestionnaireValue(questionnaire.getQuestionnaireId(), questionnaire.getComment(), questionnaire.getCategory(), questionnaire.getQuestionnaireDate(), questionnaire.getUsername(), questionnaire.getEvaluation());
}
}

View File

@@ -12,6 +12,9 @@
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Customer</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Picture</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.JwtRefreshToken</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer</class>
<class>marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire</class>
<properties>
<property name="hibernate.format_sql" value="false" />

View File

@@ -0,0 +1,37 @@
create sequence IF NOT EXISTS questionnaire_customer_seq start 25;
CREATE TABLE IF NOT EXISTS questionnaire_customer
(
customer_id bigint PRIMARY KEY,
customer_number varchar(150) NOT NULL,
name varchar(150) NOT NULL,
zip varchar(150),
city varchar(150),
jpa_active boolean NOT NULL,
jpa_created timestamp NOT NULL,
jpa_updated timestamp NOT NULL,
jpa_version integer NOT NULL,
CONSTRAINT unq_questionnaire_customer_number UNIQUE(customer_number)
);
create sequence IF NOT EXISTS questionnaire_seq start 25;
CREATE TABLE IF NOT EXISTS questionnaire
(
questionnaire_id bigint PRIMARY KEY,
username varchar(150),
questionnaire_date timestamp NOT NULL,
comment text,
questions text,
category varchar(250),
evaluation bigint NOT NULL,
jpa_active boolean NOT NULL,
jpa_created timestamp NOT NULL,
jpa_updated timestamp NOT NULL,
jpa_version integer NOT NULL,
customer_id_fk bigint REFERENCES questionnaire_customer
);

View File

@@ -0,0 +1,163 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "Publish Questionnaire",
"description": "Publish a completed questionnaire to the system",
"type": "object",
"properties": {
"username": {
"description": "The username from the user who submits the questionnaire",
"type": "string"
},
"pharmacyName": {
"description": "The name of the pharmacy customer",
"type": "string"
},
"customerNumber": {
"description": "The unique number of the pharmacy customer",
"type": "string"
},
"date": {
"description": "The date when the questionnaire was filled in (ISO 8601)",
"type": "string"
},
"comment": {
"description": "A free text comment field",
"type": "string"
},
"city": {
"description": "The city of the customer",
"type": "string"
},
"zip": {
"description": "The zip code of the customer",
"type": "string"
},
"questionnaire": {
"description": "The list of questions and answers",
"type": "array",
"items": {
"$ref": "#/definitions/question"
},
"minItems": 1
}
},
"required": [
"username",
"pharmacyName",
"customerNumber",
"date",
"questionnaire"
],
"definitions": {
"answer": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier of the answer",
"type": "string"
},
"answer": {
"description": "The answer text",
"type": "string"
},
"selected": {
"description": "Whether this answer is selected",
"type": "boolean"
}
},
"required": [
"id",
"answer",
"selected"
]
},
"matrixSubQuestion": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier of the sub-question",
"type": "string"
},
"title": {
"description": "The title of the sub-question",
"type": "string"
},
"order": {
"description": "The display order of the sub-question",
"type": "integer"
},
"answers": {
"description": "The list of answers for the sub-question",
"type": "array",
"items": {
"$ref": "#/definitions/answer"
},
"minItems": 1
}
},
"required": [
"id",
"title",
"order",
"answers"
]
},
"questionData": {
"type": "object",
"properties": {
"answers": {
"description": "The list of answers (for singleChoice, multiplChoice, number, freeText)",
"type": "array",
"items": {
"$ref": "#/definitions/answer"
}
},
"questions": {
"description": "The list of sub-questions (for matrix type)",
"type": "array",
"items": {
"$ref": "#/definitions/matrixSubQuestion"
}
}
}
},
"question": {
"type": "object",
"properties": {
"id": {
"description": "The unique identifier of the question",
"type": "string"
},
"title": {
"description": "The title/text of the question",
"type": "string"
},
"order": {
"description": "The display order of the question",
"type": "integer"
},
"type": {
"description": "The type of question",
"type": "string",
"enum": [
"singleChoice",
"multiplChoice",
"number",
"freeText",
"matrix"
]
},
"data": {
"$ref": "#/definitions/questionData"
}
},
"required": [
"id",
"title",
"order",
"type",
"data"
]
}
}
}

View File

@@ -0,0 +1,252 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import org.apache.poi.xssf.usermodel.XSSFRow;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 19 Feb 2026
*/
class ExcelUtilsTest implements TestAble {
private ExcelUtils excelUtils;
@BeforeEach
void setUp() {
excelUtils = new ExcelUtils();
}
// --- create: basic output ---
@Test
void create_singleQuestionnaire_returnsPresent() {
QuestionnaireCustomer customer = createCustomer("Müller GmbH", "C-001", "Berlin", "10115");
Questionnaire questionnaire = createQuestionnaire(new Date(), "What is your rating?");
Optional<byte[]> result = excelUtils.create(customer, List.of(questionnaire));
assertTrue(result.isPresent());
}
@Test
void create_singleQuestionnaire_returnsValidXlsx() throws IOException {
QuestionnaireCustomer customer = createCustomer("Müller GmbH", "C-001", "Berlin", "10115");
Questionnaire questionnaire = createQuestionnaire(new Date(), "What is your rating?");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertNotNull(workbook);
}
}
@Test
void create_emptyQuestionnaires_returnsEmptyWorkbook() throws IOException {
QuestionnaireCustomer customer = createCustomer("Müller GmbH", "C-001", "Berlin", "10115");
byte[] bytes = excelUtils.create(customer, Collections.emptyList()).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals(0, workbook.getNumberOfSheets());
}
}
// --- create: sheet count ---
@Test
void create_singleQuestionnaire_createsOneSheet() throws IOException {
QuestionnaireCustomer customer = createCustomer("Müller GmbH", "C-001", "Berlin", "10115");
Questionnaire questionnaire = createQuestionnaire(new Date(), "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals(1, workbook.getNumberOfSheets());
}
}
@Test
void create_multipleQuestionnaires_createsSheetPerQuestionnaire() throws IOException {
QuestionnaireCustomer customer = createCustomer("Müller GmbH", "C-001", "Berlin", "10115");
Questionnaire q1 = createQuestionnaire(new Date(), "Q1");
Questionnaire q2 = createQuestionnaire(new Date(), "Q2");
Questionnaire q3 = createQuestionnaire(new Date(), "Q3");
byte[] bytes = excelUtils.create(customer, List.of(q1, q2, q3)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals(3, workbook.getNumberOfSheets());
}
writeToFile(bytes, "create_multipleQuestionnaires_createsSheetPerQuestionnaire.xlsx");
}
// --- create: customer data (each field on its own row) ---
@Test
void create_writesCustomerNameInRow0() throws IOException {
QuestionnaireCustomer customer = createCustomer("Hartmann AG", "C-100", "München", "80331");
Questionnaire questionnaire = createQuestionnaire(new Date(), "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals("Hartmann AG", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
}
}
@Test
void create_writesCustomerNumberInRow1() throws IOException {
QuestionnaireCustomer customer = createCustomer("Hartmann AG", "C-100", "München", "80331");
Questionnaire questionnaire = createQuestionnaire(new Date(), "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals("C-100", workbook.getSheetAt(0).getRow(1).getCell(0).getStringCellValue());
}
}
@Test
void create_writesCustomerCityInRow2() throws IOException {
QuestionnaireCustomer customer = createCustomer("Hartmann AG", "C-100", "München", "80331");
Questionnaire questionnaire = createQuestionnaire(new Date(), "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals("München", workbook.getSheetAt(0).getRow(2).getCell(0).getStringCellValue());
}
}
@Test
void create_writesCustomerZipInRow3() throws IOException {
QuestionnaireCustomer customer = createCustomer("Hartmann AG", "C-100", "München", "80331");
Questionnaire questionnaire = createQuestionnaire(new Date(), "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
assertEquals("80331", workbook.getSheetAt(0).getRow(3).getCell(0).getStringCellValue());
}
}
// --- create: questionnaire date in row 4 ---
@Test
void create_writesQuestionnaireDateInRow4() throws IOException {
QuestionnaireCustomer customer = createCustomer("Test", "C-001", "Berlin", "10115");
Date date = new Date();
Questionnaire questionnaire = createQuestionnaire(date, "Q1");
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
XSSFRow row = workbook.getSheetAt(0).getRow(4);
assertEquals(date, row.getCell(0).getDateCellValue());
}
}
// --- create: question data in row 7 (after 5 customer rows + 2 blank rows) ---
@Test
void create_writesFirstQuestionTitleInRow7() throws IOException {
QuestionnaireCustomer customer = createCustomer("Test", "C-001", "Berlin", "10115");
Questionnaire questionnaire = createQuestionnaire(new Date(), null);
byte[] bytes = excelUtils.create(customer, List.of(questionnaire)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
XSSFSheet sheet = workbook.getSheetAt(0);
// Row 7 should contain the title of the first question from testJson1
XSSFRow row = sheet.getRow(7);
assertNotNull(row);
assertNotNull(row.getCell(0));
}
}
// --- create: null handling ---
@Test
void create_nullCustomerFields_writesNullsWithoutError() {
QuestionnaireCustomer customer = createCustomer(null, null, null, null);
Questionnaire questionnaire = createQuestionnaire(null, null);
Optional<byte[]> result = excelUtils.create(customer, List.of(questionnaire));
assertTrue(result.isPresent());
}
// --- create: each sheet gets same customer data ---
@Test
void create_multipleSheets_eachSheetHasCustomerData() throws IOException {
QuestionnaireCustomer customer = createCustomer("Hartmann AG", "C-100", "München", "80331");
Questionnaire q1 = createQuestionnaire(new Date(), "Q1");
Questionnaire q2 = createQuestionnaire(new Date(), "Q2");
byte[] bytes = excelUtils.create(customer, List.of(q1, q2)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
XSSFSheet sheet = workbook.getSheetAt(i);
assertEquals("Hartmann AG", sheet.getRow(0).getCell(0).getStringCellValue());
assertEquals("C-100", sheet.getRow(1).getCell(0).getStringCellValue());
}
}
}
@Test
void create_multipleSheets_eachSheetHasQuestionData() throws IOException {
QuestionnaireCustomer customer = createCustomer("Test", "C-001", "Berlin", "10115");
Questionnaire q1 = createQuestionnaire(new Date(), null);
Questionnaire q2 = createQuestionnaire(new Date(), null);
byte[] bytes = excelUtils.create(customer, List.of(q1, q2)).orElseThrow();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) {
for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
XSSFRow row = workbook.getSheetAt(i).getRow(7);
assertNotNull(row);
}
}
}
// --- helpers ---
private QuestionnaireCustomer createCustomer(String name, String number, String city, String zip) {
return new QuestionnaireCustomer.Builder()
.name(name)
.customerNumber(number)
.city(city)
.zip(zip)
.build();
}
private Questionnaire createQuestionnaire(Date date, String questions) {
return new Questionnaire.Builder()
.questionnaireDate(date)
.questions(QuestionnaireJsonParserTest.testJson1)
.build();
}
}

View File

@@ -186,6 +186,194 @@ class ImageUtilTest {
assertEquals((byte) 0xFF, result[2]);
}
// --- EXIF orientation: applyOrientation ---
@Test
void applyOrientation_orientation1_returnsSameInstance() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 1);
assertSame(image, result);
}
@Test
void applyOrientation_orientation2_horizontalFlip_preservesDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 2);
assertEquals(800, result.getWidth());
assertEquals(600, result.getHeight());
}
@Test
void applyOrientation_orientation3_rotate180_preservesDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 3);
assertEquals(800, result.getWidth());
assertEquals(600, result.getHeight());
}
@Test
void applyOrientation_orientation4_verticalFlip_preservesDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 4);
assertEquals(800, result.getWidth());
assertEquals(600, result.getHeight());
}
@Test
void applyOrientation_orientation5_swapsDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 5);
assertEquals(600, result.getWidth());
assertEquals(800, result.getHeight());
}
@Test
void applyOrientation_orientation6_rotate90cw_swapsDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 6);
assertEquals(600, result.getWidth());
assertEquals(800, result.getHeight());
}
@Test
void applyOrientation_orientation7_swapsDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 7);
assertEquals(600, result.getWidth());
assertEquals(800, result.getHeight());
}
@Test
void applyOrientation_orientation8_rotate90ccw_swapsDimensions() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 8);
assertEquals(600, result.getWidth());
assertEquals(800, result.getHeight());
}
@Test
void applyOrientation_unknownOrientation_returnsSameInstance() {
BufferedImage image = new BufferedImage(800, 600, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 99);
assertSame(image, result);
}
@Test
void applyOrientation_orientation2_flipsPixelsHorizontally() {
BufferedImage image = new BufferedImage(4, 2, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, 0xFF0000); // red at top-left
BufferedImage result = imageUtil.applyOrientation(image, 2);
assertEquals(0xFF0000, result.getRGB(3, 0) & 0xFFFFFF); // red at top-right
}
@Test
void applyOrientation_orientation3_rotates180() {
BufferedImage image = new BufferedImage(4, 2, BufferedImage.TYPE_INT_RGB);
image.setRGB(0, 0, 0xFF0000); // red at top-left
BufferedImage result = imageUtil.applyOrientation(image, 3);
assertEquals(0xFF0000, result.getRGB(3, 1) & 0xFFFFFF); // red at bottom-right
}
@Test
void applyOrientation_orientation6_rotates90cw_returnsNewImage() {
BufferedImage image = new BufferedImage(4, 2, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 6);
assertNotSame(image, result);
assertEquals(2, result.getWidth());
assertEquals(4, result.getHeight());
}
@Test
void applyOrientation_orientation8_rotates90ccw_returnsNewImage() {
BufferedImage image = new BufferedImage(4, 2, BufferedImage.TYPE_INT_RGB);
BufferedImage result = imageUtil.applyOrientation(image, 8);
assertNotSame(image, result);
assertEquals(2, result.getWidth());
assertEquals(4, result.getHeight());
}
// --- EXIF orientation: getExifOrientation ---
@Test
void getExifOrientation_pngWithNoExif_returns1() {
String base64 = createTestImageBase64(100, 100);
byte[] imageBytes = Base64.getDecoder().decode(base64);
int orientation = imageUtil.getExifOrientation(imageBytes);
assertEquals(1, orientation);
}
@Test
void getExifOrientation_invalidBytes_returns1() {
byte[] garbage = new byte[] { 0x00, 0x01, 0x02, 0x03 };
int orientation = imageUtil.getExifOrientation(garbage);
assertEquals(1, orientation);
}
// --- EXIF orientation through getImage (size 1 / default) ---
@Test
void getImage_size1_noExif_returnsOriginalUnchanged() {
String base64 = createTestImageBase64(400, 300);
byte[] original = Base64.getDecoder().decode(base64);
byte[] result = imageUtil.getImage(base64, 1);
assertArrayEquals(original, result);
}
@Test
void getImage_defaultSize_noExif_returnsOriginalUnchanged() {
String base64 = createTestImageBase64(400, 300);
byte[] original = Base64.getDecoder().decode(base64);
byte[] result = imageUtil.getImage(base64, 42);
assertArrayEquals(original, result);
}
// --- EXIF orientation with resize ---
@Test
void getImage_size2_rotatedImage_usesEffectiveWidthForResizeDecision() {
// A tall image (600x800) with no EXIF won't be resized since width (600) < 1200
String base64 = createTestImageBase64(600, 800);
byte[] original = Base64.getDecoder().decode(base64);
byte[] result = imageUtil.getImage(base64, 2);
assertArrayEquals(original, result);
}
// --- Edge cases ---
@Test

View File

@@ -78,7 +78,7 @@ class PdfUtilsTest {
byte[] pdfBytes = pdfUtils.createPdf(customer, pictures);
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
assertEquals(3, document.getNumberOfPages());
assertEquals(4, document.getNumberOfPages());
}
writeToFile(pdfBytes, "createPdf_multiplePictures_createsOnPagePerPicture.pdf");
}

View File

@@ -0,0 +1,349 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 20 Feb 2026
*/
public class QuestionnaireJsonParserTest {
@Test
public void testJson1() {
var parser = new QuestionnaireJsonParser();
boolean retVal = parser.parse(testJson1);
assertTrue(retVal);
var questions = parser.getQuestions();
assertEquals(10, questions.size());
}
public static final String testJson1 = """
[
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig? (Privat) un GKV",
"order": 5,
"type": "number",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "47",
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data":
{
"questions":
[
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]
""";
}

View File

@@ -0,0 +1,270 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Optional;
import org.junit.jupiter.api.Test;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 20 Feb 2026
*/
class QuestionnaireUploadJsonParserTest {
// --- builder: successful parsing ---
@Test
void builder_validJson_returnsPresent() {
String json = createValidJson();
Optional<QuestionnaireUploadJsonParser> result = QuestionnaireUploadJsonParser.builder(json);
assertTrue(result.isPresent());
}
@Test
void builder_validJson_parsesUsername() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("verboomp", parser.username());
}
@Test
void builder_validJson_parsesPharmacyName() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("Müller Apotheke", parser.pharmacyName());
}
@Test
void builder_validJson_parsesCustomerNumber() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("1234", parser.customerNumber());
}
@Test
void builder_validJson_parsesDate() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertNotNull(parser.date());
}
@Test
void builder_validJson_parsesComment() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("Some long text", parser.comment());
}
@Test
void builder_validJson_parsesCity() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("Hannover", parser.city());
}
@Test
void builder_validJson_parsesZip() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("12345", parser.zip());
}
@Test
void builder_validJson_parsesQuestionnaireAsJsonString() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertNotNull(parser.questionnair());
assertTrue(parser.questionnair().contains("question1"));
assertTrue(parser.questionnair().contains("singleChoice"));
}
@Test
void builder_validJson_questionnaireContainsAnswers() {
String json = createValidJson();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertTrue(parser.questionnair().contains("answer1"));
assertTrue(parser.questionnair().contains("Ja"));
}
// --- builder: multiple questions ---
@Test
void builder_multipleQuestions_allQuestionsInOutput() {
String json = createJsonWithMultipleQuestions();
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertTrue(parser.questionnair().contains("question1"));
assertTrue(parser.questionnair().contains("question2"));
}
// --- builder: invalid input ---
@Test
void builder_invalidJson_returnsEmpty() {
String json = "not valid json";
Optional<QuestionnaireUploadJsonParser> result = QuestionnaireUploadJsonParser.builder(json);
assertTrue(result.isEmpty());
}
@Test
void builder_emptyObject_returnsEmpty() {
String json = "{}";
Optional<QuestionnaireUploadJsonParser> result = QuestionnaireUploadJsonParser.builder(json);
assertTrue(result.isEmpty());
}
@Test
void builder_missingRequiredField_returnsEmpty() {
String json = """
{
"username": "verboomp",
"pharmacyName": "Test"
}""";
Optional<QuestionnaireUploadJsonParser> result = QuestionnaireUploadJsonParser.builder(json);
assertTrue(result.isEmpty());
}
@Test
void builder_invalidDateFormat_returnsEmpty() {
String json = """
{
"username": "verboomp",
"pharmacyName": "Test",
"customerNumber": "1234",
"date": "not-a-date",
"comment": "test",
"city": "Berlin",
"zip": "10115",
"questionnaire": [{"id": "q1", "title": "Q", "order": 1, "type": "freeText", "data": {"answers": []}}]
}""";
Optional<QuestionnaireUploadJsonParser> result = QuestionnaireUploadJsonParser.builder(json);
assertTrue(result.isEmpty());
}
// --- builder: special characters ---
@Test
void builder_unicodeCharacters_parsedCorrectly() {
String json = createJsonWithValues("user1", "Löwen Apotheke", "5678", "Köln", "50667");
QuestionnaireUploadJsonParser parser = QuestionnaireUploadJsonParser.builder(json).orElseThrow();
assertEquals("Löwen Apotheke", parser.pharmacyName());
assertEquals("Köln", parser.city());
}
// --- helpers ---
private String createValidJson() {
return """
{
"username": "verboomp",
"pharmacyName": "Müller Apotheke",
"customerNumber": "1234",
"date": "2026-01-20T11:06:00+01:00",
"comment": "Some long text",
"city": "Hannover",
"zip": "12345",
"questionnaire": [
{
"id": "question1",
"title": "Kundentyp",
"order": 1,
"type": "singleChoice",
"data": {
"answers": [
{"id": "answer1", "answer": "Ja", "selected": true},
{"id": "answer2", "answer": "Nein", "selected": false}
]
}
}
]
}""";
}
private String createJsonWithMultipleQuestions() {
return """
{
"username": "verboomp",
"pharmacyName": "Test Apotheke",
"customerNumber": "9999",
"date": "2026-01-20T11:06:00+01:00",
"comment": "comment",
"city": "Berlin",
"zip": "10115",
"questionnaire": [
{
"id": "question1",
"title": "First",
"order": 1,
"type": "singleChoice",
"data": {"answers": [{"id": "a1", "answer": "Yes", "selected": true}]}
},
{
"id": "question2",
"title": "Second",
"order": 2,
"type": "freeText",
"data": {"answers": [{"id": "a1", "answer": "Some text", "selected": true}]}
}
]
}""";
}
private String createJsonWithValues(String username, String pharmacyName, String customerNumber, String city, String zip) {
return """
{
"username": "%s",
"pharmacyName": "%s",
"customerNumber": "%s",
"date": "2026-01-20T11:06:00+01:00",
"comment": "test",
"city": "%s",
"zip": "%s",
"questionnaire": [
{
"id": "q1",
"title": "Q",
"order": 1,
"type": "freeText",
"data": {"answers": [{"id": "a1", "answer": "text", "selected": true}]}
}
]
}""".formatted(username, pharmacyName, customerNumber, city, zip);
}
}

View File

@@ -0,0 +1,33 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import java.io.File;
import java.io.FileOutputStream;
import org.apache.commons.io.IOUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 23 Feb 2026
*/
public interface TestAble {
public static final Log LOG = LogFactory.getLog(ExcelUtilsTest.class);
default void writeToFile(final byte[] content, final String fileName) {
File file = new File("target/test/output/");
file.mkdirs();
try (FileOutputStream out = new FileOutputStream(new File(file, fileName))) {
IOUtils.write(content, out);
} catch (Exception e) {
LOG.error("Error saveing pdf file", e);
}
}
}

View File

@@ -0,0 +1,224 @@
package marketing.heyday.hartmann.fotodocumentation.core.utils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.*;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import marketing.heyday.hartmann.fotodocumentation.core.model.Questionnaire;
import marketing.heyday.hartmann.fotodocumentation.core.model.QuestionnaireCustomer;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 23 Feb 2026
*/
class ZipExportUtilsTest implements TestAble {
private ZipExportUtils zipExportUtils;
private ExcelUtils excelUtils;
@BeforeEach
void setUp() throws Exception {
zipExportUtils = new ZipExportUtils();
excelUtils = mock(ExcelUtils.class);
Field field = ZipExportUtils.class.getDeclaredField("excelUtils");
field.setAccessible(true);
field.set(zipExportUtils, excelUtils);
}
// --- getExport: empty input ---
@Test
void getExport_emptyList_returnsEmpty() {
Optional<byte[]> result = zipExportUtils.getExport(Collections.emptyList());
assertTrue(result.isEmpty());
}
// --- getExport: single customer ---
@Test
void getExport_singleCustomer_returnsPresent() {
QuestionnaireCustomer customer = createCustomerWithQuestionnaires("Müller Apotheke", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(new byte[] { 1, 2, 3 }));
Optional<byte[]> result = zipExportUtils.getExport(List.of(customer));
assertTrue(result.isPresent());
}
@Test
void getExport_singleCustomer_returnsValidZip() throws IOException {
QuestionnaireCustomer customer = createCustomerWithQuestionnaires("Müller Apotheke", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(new byte[] { 1, 2, 3 }));
byte[] bytes = zipExportUtils.getExport(List.of(customer)).orElseThrow();
List<String> entries = getZipEntryNames(bytes);
assertEquals(1, entries.size());
}
@Test
void getExport_singleCustomer_zipEntryNamedAfterCustomer() throws IOException {
QuestionnaireCustomer customer = createCustomerWithQuestionnaires("Müller Apotheke", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(new byte[] { 1, 2, 3 }));
byte[] bytes = zipExportUtils.getExport(List.of(customer)).orElseThrow();
List<String> entries = getZipEntryNames(bytes);
assertEquals("Müller Apotheke.xlsx", entries.get(0));
}
@Test
void getExport_singleCustomer_zipEntryContainsExcelContent() throws IOException {
byte[] excelContent = new byte[] { 10, 20, 30, 40, 50 };
QuestionnaireCustomer customer = createCustomerWithQuestionnaires("Test", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(excelContent));
byte[] bytes = zipExportUtils.getExport(List.of(customer)).orElseThrow();
byte[] entryContent = getZipEntryContent(bytes, "Test.xlsx");
assertArrayEquals(excelContent, entryContent);
}
// --- getExport: multiple customers ---
@Test
void getExport_multipleCustomers_createsEntryPerCustomer() throws IOException {
QuestionnaireCustomer c1 = createCustomerWithQuestionnaires("Apotheke A", 1);
QuestionnaireCustomer c2 = createCustomerWithQuestionnaires("Apotheke B", 1);
QuestionnaireCustomer c3 = createCustomerWithQuestionnaires("Apotheke C", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(new byte[] { 1 }));
byte[] bytes = zipExportUtils.getExport(List.of(c1, c2, c3)).orElseThrow();
List<String> entries = getZipEntryNames(bytes);
assertEquals(3, entries.size());
assertTrue(entries.contains("Apotheke A.xlsx"));
assertTrue(entries.contains("Apotheke B.xlsx"));
assertTrue(entries.contains("Apotheke C.xlsx"));
writeToFile(bytes, "getExport_multipleCustomers_createsEntryPerCustomer.zip");
}
// --- getExport: excel creation fails ---
@Test
void getExport_excelReturnsEmpty_returnsEmpty() {
QuestionnaireCustomer customer = createCustomerWithQuestionnaires("Test", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.empty());
Optional<byte[]> result = zipExportUtils.getExport(List.of(customer));
assertTrue(result.isEmpty());
}
@Test
void getExport_someExcelsFail_onlyIncludesSuccessful() throws IOException {
QuestionnaireCustomer c1 = createCustomerWithQuestionnaires("Success", 1);
QuestionnaireCustomer c2 = createCustomerWithQuestionnaires("Fail", 1);
when(excelUtils.create(eq(c1), anyList())).thenReturn(Optional.of(new byte[] { 1 }));
when(excelUtils.create(eq(c2), anyList())).thenReturn(Optional.empty());
byte[] bytes = zipExportUtils.getExport(List.of(c1, c2)).orElseThrow();
List<String> entries = getZipEntryNames(bytes);
assertEquals(1, entries.size());
assertEquals("Success.xlsx", entries.get(0));
}
// --- getExport: questionnaire sorting ---
@Test
void getExport_questionnairesPassedSortedByDate() {
QuestionnaireCustomer customer = new QuestionnaireCustomer.Builder()
.name("Test").customerNumber("C-001").build();
Questionnaire q1 = new Questionnaire.Builder().questionnaireDate(new Date(2000)).build();
Questionnaire q2 = new Questionnaire.Builder().questionnaireDate(new Date(1000)).build();
Questionnaire q3 = new Questionnaire.Builder().questionnaireDate(new Date(3000)).build();
customer.getQuestionnaires().addAll(Set.of(q1, q2, q3));
when(excelUtils.create(any(), anyList())).thenAnswer(invocation -> {
List<Questionnaire> questionnaires = invocation.getArgument(1);
for (int i = 0; i < questionnaires.size() - 1; i++) {
assertTrue(questionnaires.get(i).getQuestionnaireDate()
.compareTo(questionnaires.get(i + 1).getQuestionnaireDate()) <= 0,
"Questionnaires should be sorted by date");
}
return Optional.of(new byte[] { 1 });
});
zipExportUtils.getExport(List.of(customer));
verify(excelUtils).create(eq(customer), anyList());
}
// --- getExport: calls excelUtils correctly ---
@Test
void getExport_callsExcelUtilsForEachCustomer() {
QuestionnaireCustomer c1 = createCustomerWithQuestionnaires("A", 1);
QuestionnaireCustomer c2 = createCustomerWithQuestionnaires("B", 1);
when(excelUtils.create(any(), anyList())).thenReturn(Optional.of(new byte[] { 1 }));
zipExportUtils.getExport(List.of(c1, c2));
verify(excelUtils).create(eq(c1), anyList());
verify(excelUtils).create(eq(c2), anyList());
}
// --- helpers ---
private QuestionnaireCustomer createCustomerWithQuestionnaires(String name, int questionnaireCount) {
QuestionnaireCustomer customer = new QuestionnaireCustomer.Builder()
.name(name).customerNumber("C-" + name.hashCode()).build();
for (int i = 0; i < questionnaireCount; i++) {
Questionnaire q = new Questionnaire.Builder()
.questionnaireDate(new Date(1000L * (i + 1)))
.questions("[]")
.build();
customer.getQuestionnaires().add(q);
}
return customer;
}
private List<String> getZipEntryNames(byte[] zipBytes) throws IOException {
List<String> names = new ArrayList<>();
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
names.add(entry.getName());
}
}
return names;
}
private byte[] getZipEntryContent(byte[] zipBytes, String entryName) throws IOException {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes))) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.getName().equals(entryName)) {
return zis.readAllBytes();
}
}
}
throw new IOException("Entry not found: " + entryName);
}
}

View File

@@ -0,0 +1,20 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 13 Nov 2024
*/
public abstract class AbstractFotoTest extends AbstractRestTest {
protected int customerCount() {
return getCount("select count(*) from customer");
}
protected int pictureCount() {
return getCount("select count(*) from picture");
}
}

View File

@@ -0,0 +1,20 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 13 Nov 2024
*/
public abstract class AbstractQuestionnaireTest extends AbstractRestTest {
protected int customerCount() {
return getCount("select count(*) from questionnaire_customer");
}
protected int questionnaireCount() {
return getCount("select count(*) from questionnaire");
}
}

View File

@@ -112,12 +112,4 @@ public abstract class AbstractRestTest extends AbstractTest {
String className = this.getClass().getName();
return getResponseText(httpResponse, () -> className + "-" + name + ".json");
}
protected int customerCount() {
return getCount("select count(*) from customer");
}
protected int pictureCount() {
return getCount("select count(*) from picture");
}
}

View File

@@ -85,6 +85,8 @@ public abstract class AbstractTest {
repDataSet.addReplacementObject("NULL_DATE", null);
repDataSet.addReplacementObject("NULL_NUMBER", null);
repDataSet.addReplacementObject("NULL_STRING", null);
repDataSet.addReplacementObject("QUESTIONNAIRE_JSON", QUESTIONNAIRE_JSON);
try {
DatabaseOperation.CLEAN_INSERT.execute(conn, repDataSet);
} finally {
@@ -179,4 +181,323 @@ public abstract class AbstractTest {
throw new RuntimeException(e);
}
}
private static final String QUESTIONNAIRE_JSON = """
[
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig? (Privat) un GKV",
"order": 5,
"type": "number",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": 47,
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data":
{
"questions":
[
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]
""";
}

View File

@@ -26,7 +26,7 @@ import org.junit.jupiter.api.TestMethodOrder;
* created: 14 Nov 2024
*/
@TestMethodOrder(OrderAnnotation.class)
public class CustomerPictureResourceTest extends AbstractRestTest {
public class CustomerPictureResourceTest extends AbstractFotoTest {
private static final Log LOG = LogFactory.getLog(CustomerPictureResourceTest.class);
private static final String PATH = "api/customer-picture";
private static final String BASE_UPLOAD = "src/test/resources/upload/";
@@ -64,11 +64,8 @@ public class CustomerPictureResourceTest extends AbstractRestTest {
assertEquals(3, customerCount());
assertEquals(6, pictureCount());
String authorization = getBasicHeader();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH;
Request request = Request.Post(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization)
.bodyFile(new File(BASE_UPLOAD + "addNewCustomer.json"), ContentType.APPLICATION_JSON);
HttpResponse httpResponse = executeRequest(request);
@@ -84,11 +81,8 @@ public class CustomerPictureResourceTest extends AbstractRestTest {
public void doAddCustomerPictureWrongJson() throws IOException {
LOG.info("doAddCustomerPictureWrongJson");
String authorization = getBasicHeader();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH;
Request request = Request.Post(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization)
.bodyFile(new File(BASE_UPLOAD + "addWrong.json"), ContentType.APPLICATION_JSON);
HttpResponse httpResponse = executeRequest(request);

View File

@@ -24,7 +24,7 @@ import org.junit.jupiter.api.TestMethodOrder;
* created: 14 Nov 2024
*/
@TestMethodOrder(OrderAnnotation.class)
public class PictureResourceTest extends AbstractRestTest {
public class PictureResourceTest extends AbstractFotoTest {
private static final Log LOG = LogFactory.getLog(PictureResourceTest.class);
private static final String PATH = "api/picture";
@@ -178,4 +178,16 @@ public class PictureResourceTest extends AbstractRestTest {
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(404, code);
}
public static void main(String[] args) throws IOException {
var test = new PictureResourceTest();
test.deploymentURL = "http://localhost:8080/";
test.deploymentURL = "https://hartmann-cue.heydevelop.de/";
test.username = "adm";
test.password = "x1t0e7Pb49";
test.doGetPicture();
}
}

View File

@@ -0,0 +1,251 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpResponse;
import org.apache.http.client.fluent.Request;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 14 Nov 2024
*/
@TestMethodOrder(OrderAnnotation.class)
public class QuestionnaireCustomerResourceTest extends AbstractRestTest {
private static final Log LOG = LogFactory.getLog(QuestionnaireCustomerResourceTest.class);
private static final String PATH = "api/questionnairecustomer";
private static final String BASE_DOWNLOAD = "json/QuestionnaireCustomerResourceTest-";
@BeforeAll
public static void init() {
initDB();
}
@Test
@Order(1)
public void doGetAll() throws IOException {
LOG.info("doGetAll");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH;
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAll");
String expected = fileToString(BASE_DOWNLOAD + "doGetAll.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllStartWith() throws IOException {
LOG.info("doGetAllStartWith");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "?startsWith=M";
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllStartWith");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllStartWith.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllQueryText() throws IOException {
LOG.info("doGetAllQueryText");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "?query=2345";
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllQueryText");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllQueryText.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllQueryTextWithStart() throws IOException {
LOG.info("doGetAllQueryTextWithStart");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "?query=45&startsWith=M";
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllQueryTextWithStart");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllQueryTextWithStart.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllQueryDate1() throws IOException {
LOG.info("doGetAllQueryDate");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "?query=12.01.2026";
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllQueryDate");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllQueryDate.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllQueryDate2() throws IOException {
LOG.info("doGetAllQueryDate");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String query = URLEncoder.encode("12 Januar 2026", Charset.forName("utf-8"));
String path = deploymentURL + PATH + "?query=" + query;
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllQueryDate");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllQueryDate.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetAllQueryDate3() throws IOException {
LOG.info("doGetAllQueryDate");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String query = URLEncoder.encode("12. Januar 2026", Charset.forName("utf-8"));
String path = deploymentURL + PATH + "?query=" + query;
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetAllQueryDate");
String expected = fileToString(BASE_DOWNLOAD + "doGetAllQueryDate.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doGetCustomer() throws IOException {
LOG.info("doGetCustomer");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "/1";
Request request = Request.Get(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
String text = getResponseText(httpResponse, "doGetCustomer");
String expected = fileToString(BASE_DOWNLOAD + "doGetCustomer.json");
jsonAssert(expected, text);
}
@Test
@Order(1)
public void doDownloadZip() throws IOException {
LOG.info("doDownload");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "/exportall";
Request request = Request.Get(path).addHeader("Accept", "application/zip")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
byte[] text = getResponse(httpResponse);
writeFile(text, "doDownload.zip");
}
@Test
@Order(1)
public void doDownloadExcel() throws IOException {
LOG.info("doDownload");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "/export/1";
Request request = Request.Get(path).addHeader("Accept", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
byte[] text = getResponse(httpResponse);
writeFile(text, "doDownload.xlsx");
}
@Test
@Order(1)
public void doDownloadExcelNotExist() throws IOException {
LOG.info("doDownloadNotExist");
String authorization = getAuthorization();
LOG.info("authorization: " + authorization);
String path = deploymentURL + PATH + "/export/9999";
Request request = Request.Get(path).addHeader("Accept", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.addHeader("Authorization", authorization);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(404, code);
}
}

View File

@@ -0,0 +1,92 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.io.File;
import java.io.IOException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpResponse;
import org.apache.http.client.fluent.Request;
import org.apache.http.entity.ContentType;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 14 Nov 2024
*/
@TestMethodOrder(OrderAnnotation.class)
public class QuestionnairePublishResourceTest extends AbstractQuestionnaireTest {
private static final Log LOG = LogFactory.getLog(QuestionnairePublishResourceTest.class);
private static final String PATH = "api/questionnaire-publish";
private static final String BASE_UPLOAD = "src/test/resources/upload/";
@BeforeAll
public static void init() {
initDB();
}
@Test
@Order(2)
public void doAddCustomerdoAddQuestionniare() throws IOException {
LOG.info("doAddCustomerdoAddQuestionniare");
assertEquals(3, customerCount());
assertEquals(5, questionnaireCount());
String path = deploymentURL + PATH;
Request request = Request.Post(path)//.addHeader("Accept", "application/json; charset=utf-8")
.bodyFile(new File(BASE_UPLOAD + "questionnaire_add.json"), ContentType.APPLICATION_JSON);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
assertEquals(3, customerCount());
assertEquals(6, questionnaireCount());
}
@Test
@Order(3)
public void doAddCustomerWithQuestionnaire() throws IOException {
LOG.info("doAddCustomerWithQuestionnaire");
assertEquals(3, customerCount());
assertEquals(6, questionnaireCount());
String path = deploymentURL + PATH;
Request request = Request.Post(path).addHeader("Accept", "application/json; charset=utf-8")
.bodyFile(new File(BASE_UPLOAD + "questionnaire_addNewCustomer.json"), ContentType.APPLICATION_JSON);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
assertEquals(4, customerCount());
assertEquals(7, questionnaireCount());
}
@Test
@Order(1)
public void doAddCustomerWithQuestionnaireWrongJson() throws IOException {
LOG.info("doAddCustomerWithQuestionnaireWrongJson");
String path = deploymentURL + PATH;
Request request = Request.Post(path).addHeader("Accept", "application/json; charset=utf-8")
.bodyFile(new File(BASE_UPLOAD + "questionnaire_addWrong.json"), ContentType.APPLICATION_JSON);
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(400, code);
String text = getResponseText(httpResponse, "doGetAll");
System.out.println(text);
}
}

View File

@@ -0,0 +1,145 @@
package marketing.heyday.hartmann.fotodocumentation.rest;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpResponse;
import org.apache.http.client.fluent.Request;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
/**
*
* <p>Copyright: Copyright (c) 2024</p>
* <p>Company: heyday Marketing GmbH</p>
* @author <a href="mailto:p.verboom@heyday.marketing">Patrick Verboom</a>
* @version 1.0
*
* created: 14 Nov 2024
*/
@TestMethodOrder(OrderAnnotation.class)
public class QuestionnaireResourceTest extends AbstractQuestionnaireTest {
private static final Log LOG = LogFactory.getLog(QuestionnaireResourceTest.class);
private static final String PATH = "api/questionnaire";
@BeforeAll
public static void init() {
initDB();
}
@Test
@Order(3)
public void doDelete() throws IOException {
LOG.info("doDelete");
assertEquals(5, questionnaireCount());
String path = deploymentURL + PATH + "/1";
Request request = Request.Delete(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
assertEquals(4, questionnaireCount());
}
@Test
@Order(2)
public void doDeleteNotFound() throws IOException {
LOG.info("doDeleteNotFound");
assertEquals(5, questionnaireCount());
String path = deploymentURL + PATH + "/6000";
Request request = Request.Delete(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(404, code);
assertEquals(5, questionnaireCount());
}
@Test
@Order(1)
public void doEvaluation() throws IOException {
LOG.info("doEvaluation");
assertEquals(0, getCount("select count(*) from questionnaire where questionnaire_id = 1 and evaluation = 3"));
String path = deploymentURL + PATH + "/evaluation/1?evaluation=3";
Request request = Request.Put(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(200, code);
assertEquals(1, getCount("select count(*) from questionnaire where questionnaire_id = 1 and evaluation = 3"));
}
@Test
@Order(1)
public void doEvaluationNotFound() throws IOException {
LOG.info("doEvaluationNotFound");
String path = deploymentURL + PATH + "/evaluation/6000?evaluation=3";
Request request = Request.Put(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(404, code);
}
@Test
@Order(1)
public void doEvaluationWrongValue() throws IOException {
LOG.info("doEvaluationWrongValue");
String path = deploymentURL + PATH + "/evaluation/1?evaluation=4";
Request request = Request.Put(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(400, code);
}
@Test
@Order(1)
public void doEvaluationWrongValue2() throws IOException {
LOG.info("doEvaluationWrongValue2");
String path = deploymentURL + PATH + "/evaluation/1?evaluation=0";
Request request = Request.Put(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(400, code);
}
@Test
@Order(1)
public void doEvaluationNoValue() throws IOException {
LOG.info("doEvaluationNoValue");
String path = deploymentURL + PATH + "/evaluation/1";
Request request = Request.Put(path).addHeader("Accept", "application/json; charset=utf-8")
.addHeader("Authorization", getAuthorization());
HttpResponse httpResponse = executeRequest(request);
int code = httpResponse.getStatusLine().getStatusCode();
assertEquals(400, code);
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
[
{
"id": 3,
"name": "Schmidt Apotheke",
"customerNumber": "3456",
"lastUpdateDate": 1768212570000
}
]

View File

@@ -0,0 +1,8 @@
[
{
"id": 2,
"name": "Meier Apotheke",
"customerNumber": "2345",
"lastUpdateDate": 1767607770000
}
]

View File

@@ -0,0 +1,8 @@
[
{
"id": 2,
"name": "Meier Apotheke",
"customerNumber": "2345",
"lastUpdateDate": 1767607770000
}
]

View File

@@ -0,0 +1,14 @@
[
{
"id": 1,
"name": "Müller Apotheke",
"customerNumber": "1234",
"lastUpdateDate": 1767348570000
},
{
"id": 2,
"name": "Meier Apotheke",
"customerNumber": "2345",
"lastUpdateDate": 1767607770000
}
]

View File

@@ -0,0 +1,25 @@
{
"id": 1,
"name": "Müller Apotheke",
"customerNumber": "1234",
"city": "Hannover",
"zip": "12345",
"questionnaires": [
{
"id": 1,
"comment": "good looking picture 1",
"category": null,
"questionnaireDate": 1767262170000,
"username": "verboomp",
"evaluation": 1
},
{
"id": 2,
"comment": "good looking picture 2",
"category": null,
"questionnaireDate": 1767348570000,
"username": "verboomp",
"evaluation": 1
}
]
}

View File

@@ -0,0 +1,20 @@
[
{
"id": 1,
"name": "Müller Apotheke",
"customerNumber": "1234",
"lastUpdateDate": 1767348570000
},
{
"id": 2,
"name": "Meier Apotheke",
"customerNumber": "2345",
"lastUpdateDate": 1767607770000
},
{
"id": 3,
"name": "Schmidt Apotheke",
"customerNumber": "3456",
"lastUpdateDate": 1768212570000
}
]

View File

@@ -0,0 +1,302 @@
{
"username": "verboomp",
"pharmacyName": "Müller Apotheke",
"customerNumber": "1234",
"date": "2026-01-20T11:06:00+01:00",
"comment": "Some long text for Müller Pharmacy",
"city": "Hannover",
"zip": "12345",
"questionnaire": [
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig?\n(Privat) un GKV",
"order": 5,
"type": "number",
"data": {
"answers": [
{
"id": "answer1",
"answer": "47",
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data": {
"questions": [
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]
}

View File

@@ -0,0 +1,302 @@
{
"username": "verboomp",
"pharmacyName": "New Apotheke",
"customerNumber": "new_number",
"date": "2026-01-20T11:06:00+01:00",
"comment": "Some long text for New Pharmacy",
"city": "Hannover",
"zip": "12345",
"questionnaire": [
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig?\n(Privat) un GKV",
"order": 5,
"type": "number",
"data": {
"answers": [
{
"id": "answer1",
"answer": "47",
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data": {
"questions": [
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]
}

View File

@@ -0,0 +1,298 @@
{
"username": "verboomp",
"date": "2026-01-20T11:06:00+01:00",
"comment": "Some long text for Müller Pharmacy",
"questionnaire": [
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig?\n(Privat) un GKV",
"order": 5,
"type": "number",
"data": {
"answers": [
{
"id": "answer1",
"answer": "47",
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data": {
"questions": [
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers": [
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data": {
"answers": [
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]
}

View File

@@ -1,7 +1,7 @@
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
abstract interface class CustomerController {
abstract interface class FotoCustomerController {
Future<List<CustomerListDto>> getAll(String query, String startsWith);
Future<CustomerDto?> get({required int id});
@@ -9,7 +9,7 @@ abstract interface class CustomerController {
Future<List<int>> export({required int customerId, int? pictureId});
}
class CustomerControllerImpl extends BaseController implements CustomerController {
class FotoCustomerControllerImpl extends BaseController implements FotoCustomerController {
final String path = "customer";
@override

View File

@@ -0,0 +1,23 @@
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
abstract interface class QuestionnaireController {
Future<bool> delete(QuestionnaireDto dto);
Future<bool> updateEvaluation(QuestionnaireDto dto);
}
class QuestionnaireControllerImpl extends BaseController implements QuestionnaireController {
final String path = "questionnaire";
@override
Future<bool> delete(QuestionnaireDto dto) {
String uriStr = '${uriUtils.getBaseUrl()}$path/${dto.id}';
return runDeleteWithAuth(uriStr);
}
@override
Future<bool> updateEvaluation(QuestionnaireDto dto) {
String uriStr = '${uriUtils.getBaseUrl()}$path/evaluation/${dto.id}?evaluation=${dto.evaluation}';
return runPutWithAuth(uriStr);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
abstract interface class QuestionnaireCustomerController {
Future<List<QuestionnaireCustomerListDto>> getAll(String query, String startsWith);
Future<QuestionnaireCustomerDto?> get({required int id});
Future<List<int>> export({required int customerId, int? questionnaireId});
}
class QuestionnaireCustomerControllerImpl extends BaseController implements QuestionnaireCustomerController {
final String path = "questionnairecustomer";
@override
Future<List<QuestionnaireCustomerListDto>> getAll(String query, String startsWith) async {
String uriStr = '${uriUtils.getBaseUrl()}$path?query=$query&startsWith=$startsWith';
return runGetListWithAuth(uriStr, (p0) {
List<QuestionnaireCustomerListDto> retVal = [];
for (var elem in p0) {
var entity = QuestionnaireCustomerListDto.fromJson(elem);
retVal.add(entity);
}
return retVal;
});
}
@override
Future<QuestionnaireCustomerDto?> get({required int id}) {
String uriStr = '${uriUtils.getBaseUrl()}$path/$id';
return runGetWithAuth(uriStr, (json) => QuestionnaireCustomerDto.fromJson(json));
}
@override
Future<List<int>> export({required int customerId, int? questionnaireId}) {
String uriStr = '${uriUtils.getBaseUrl()}$path/export/$customerId';
if (questionnaireId != null) {
uriStr += '?questionnaire=$questionnaireId';
}
return runGetBytesWithAuth(uriStr);
}
}

View File

@@ -5,9 +5,11 @@ class CustomerListDto {
final int id;
final String name;
final String customerNumber;
final String? zip;
final String? city;
final DateTime? lastUpdateDate;
CustomerListDto({required this.id, required this.name, required this.customerNumber, this.lastUpdateDate});
CustomerListDto({required this.id, required this.name, required this.customerNumber, this.zip, this.city, this.lastUpdateDate});
/// Create from JSON response
factory CustomerListDto.fromJson(Map<String, dynamic> json) {
@@ -15,6 +17,8 @@ class CustomerListDto {
id: json['id'] as int,
name: json['name'] as String,
customerNumber: json['customerNumber'] as String,
zip: json['zip'] as String?,
city: json['city'] as String?,
lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']),
);
}
@@ -42,3 +46,5 @@ class CustomerDto {
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
import 'package:fotodocumentation/utils/date_time_utils.dart';
class QuestionnaireCustomerListDto {
final int id;
final String name;
final String customerNumber;
final String? zip;
final String? city;
final DateTime? lastUpdateDate;
QuestionnaireCustomerListDto({required this.id, required this.name, required this.customerNumber, this.zip, this.city, this.lastUpdateDate});
/// Create from JSON response
factory QuestionnaireCustomerListDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireCustomerListDto(
id: json['id'] as int,
name: json['name'] as String,
customerNumber: json['customerNumber'] as String,
zip: json['zip'] as String?,
city: json['city'] as String?,
lastUpdateDate: DateTimeUtils.toDateTime(json['lastUpdateDate']),
);
}
}
class QuestionnaireCustomerDto {
final int id;
final String name;
final String customerNumber;
final String? zip;
final String? city;
final List<QuestionnaireDto> questionnaires;
QuestionnaireCustomerDto({required this.id, required this.name, required this.customerNumber, required this.questionnaires, this.zip, this.city});
/// Create from JSON response
factory QuestionnaireCustomerDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireCustomerDto(
id: json['id'] as int,
name: json['name'] as String,
customerNumber: json['customerNumber'] as String,
zip: json['zip'] as String?,
city: json['city'] as String?,
questionnaires: List<QuestionnaireDto>.from(json["questionnaires"].map((x) => QuestionnaireDto.fromJson(x))),
);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:fotodocumentation/utils/date_time_utils.dart';
class QuestionnaireDto {
final int id;
final String? comment;
final String? category;
int evaluation;
final DateTime questionnaireDate;
final String? username;
QuestionnaireDto(
{required this.id, required this.comment, required this.category, required this.evaluation, required this.questionnaireDate, required this.username});
/// Create from JSON response
factory QuestionnaireDto.fromJson(Map<String, dynamic> json) {
return QuestionnaireDto(
id: json['id'] as int,
comment: json['comment'] as String?,
category: json['category'] as String?,
evaluation: json["evaluation"] as int,
questionnaireDate: DateTimeUtils.toDateTime(json['questionnaireDate']) ?? DateTime.now(),
username: json['username'] as String?,
);
}
@override
String toString() {
return 'QuestionnaireDto{id: $id}';
}
}

View File

@@ -33,7 +33,6 @@
}
}
},
"errorWidget": "Fehler: {name}",
"@errorWidget": {
"description": "Error widget text",
@@ -80,7 +79,7 @@
"@customerListHeaderName": {
"description": "Customer list table header for name"
},
"customerListHeaderLastDate": "Datum Bilder",
"customerListHeaderLastDate": "Datum",
"@customerListHeaderLastDate": {
"description": "Customer list table header for last date"
},
@@ -144,5 +143,17 @@
"pictureWidgetLabelEvaluation": "BEWERTUNG",
"@pictureWidgetLabelEvaluation": {
"description": "Picture widget label for evaluation"
},
"pictureWidgetNotFound": "Das Bild konnte nicht gefunden werden.",
"@pictureWidgetNotFound": {
"description": "Picture not found error message"
},
"customerWidgetDownloadInProgress": "Download wird vorbereitet…",
"@customerWidgetDownloadInProgress": {
"description": "Download in progress message"
},
"questionnaireLoginTitle": "FRAGEBOGEN",
"@questionnaireLoginTitle": {
"description": "Questionnaire login page title"
}
}

View File

@@ -199,7 +199,7 @@ abstract class AppLocalizations {
/// Customer list table header for last date
///
/// In de, this message translates to:
/// **'Datum Bilder'**
/// **'Datum'**
String get customerListHeaderLastDate;
/// Customer list table header for ladt date
@@ -285,6 +285,24 @@ abstract class AppLocalizations {
/// In de, this message translates to:
/// **'BEWERTUNG'**
String get pictureWidgetLabelEvaluation;
/// Picture not found error message
///
/// In de, this message translates to:
/// **'Das Bild konnte nicht gefunden werden.'**
String get pictureWidgetNotFound;
/// Download in progress message
///
/// In de, this message translates to:
/// **'Download wird vorbereitet…'**
String get customerWidgetDownloadInProgress;
/// Questionnaire login page title
///
/// In de, this message translates to:
/// **'FRAGEBOGEN'**
String get questionnaireLoginTitle;
}
class _AppLocalizationsDelegate

View File

@@ -65,7 +65,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get customerListHeaderName => 'Apothekenname';
@override
String get customerListHeaderLastDate => 'Datum Bilder';
String get customerListHeaderLastDate => 'Datum';
@override
String get customerListHeaderLastDateSuffix => ' (zuletzt aktualisiert)';
@@ -111,4 +111,13 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get pictureWidgetLabelEvaluation => 'BEWERTUNG';
@override
String get pictureWidgetNotFound => 'Das Bild konnte nicht gefunden werden.';
@override
String get customerWidgetDownloadInProgress => 'Download wird vorbereitet…';
@override
String get questionnaireLoginTitle => 'FRAGEBOGEN';
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
@@ -13,15 +13,15 @@ import 'package:fotodocumentation/utils/global_router.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class CustomerListWidget extends StatefulWidget {
const CustomerListWidget({super.key});
class FotoCustomerListWidget extends StatefulWidget {
const FotoCustomerListWidget({super.key});
@override
State<CustomerListWidget> createState() => _CustomerListWidgetState();
State<FotoCustomerListWidget> createState() => _FotoCustomerListWidgetState();
}
class _CustomerListWidgetState extends State<CustomerListWidget> {
CustomerController get _customerController => DiContainer.get();
class _FotoCustomerListWidgetState extends State<FotoCustomerListWidget> {
FotoCustomerController get _customerController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
final _searchController = TextEditingController();
@@ -138,15 +138,10 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
color: _generalStyle.secondaryWidgetBackgroundColor,
);
final headerStyleSuffix = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.normal,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 8.0,
children: [
const SizedBox(width: 48),
Expanded(
@@ -163,18 +158,32 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
style: headerStyle,
),
),
Expanded(
flex: 1,
child: Text(
"PLZ/Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Text(
"Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(Icons.calendar_month, color: _generalStyle.secondaryWidgetBackgroundColor),
),
Text(
AppLocalizations.of(context)!.customerListHeaderLastDate,
style: headerStyle,
),
Text(
AppLocalizations.of(context)!.customerListHeaderLastDateSuffix,
style: headerStyleSuffix,
),
],
),
),
@@ -196,6 +205,7 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
spacing: 8.0,
children: [
SizedBox(
width: 48,
@@ -209,6 +219,14 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
flex: 3,
child: Text(dto.name, style: dataStyle),
),
Expanded(
flex: 1,
child: Text(dto.zip ?? "", style: dataStyle),
),
Expanded(
flex: 2,
child: Text(dto.city ?? "", style: dataStyle),
),
Expanded(
flex: 2,
child: Text(dateStr, style: dataStyle),
@@ -224,7 +242,7 @@ class _CustomerListWidgetState extends State<CustomerListWidget> {
}
Future<void> _actionSelect(BuildContext context, CustomerListDto dto) async {
String uri = "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/${dto.id}";
String uri = "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/${dto.id}";
context.go(uri);
}

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/customer/picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
@@ -17,16 +17,16 @@ import 'package:go_router/go_router.dart';
import 'package:fotodocumentation/utils/file_download.dart';
import 'package:intl/intl.dart';
class CustomerWidget extends StatefulWidget {
class FotoCustomerWidget extends StatefulWidget {
final int customerId;
const CustomerWidget({super.key, required this.customerId});
const FotoCustomerWidget({super.key, required this.customerId});
@override
State<CustomerWidget> createState() => _CustomerWidgetState();
State<FotoCustomerWidget> createState() => _FotoCustomerWidgetState();
}
class _CustomerWidgetState extends State<CustomerWidget> {
CustomerController get _customerController => DiContainer.get();
class _FotoCustomerWidgetState extends State<FotoCustomerWidget> {
FotoCustomerController get _customerController => DiContainer.get();
PictureController get _pictureController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
@@ -40,6 +40,14 @@ class _CustomerWidgetState extends State<CustomerWidget> {
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant FotoCustomerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -90,7 +98,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: dto.name, subText: subText),
CustomerBackButton(path: GlobalRouter.pathHome),
CustomerBackButton(path: GlobalRouter.pathFotoHome),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -316,7 +324,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return PictureDeleteDialog();
return FotoPictureDeleteDialog();
},
);
@@ -329,7 +337,7 @@ class _CustomerWidgetState extends State<CustomerWidget> {
}
Future<void> _actionSelect(BuildContext context, CustomerDto customerDto, PictureDto pictureDto) async {
String uri = "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/${customerDto.id}${GlobalRouter.pathPicture}/${pictureDto.id}";
String uri = "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/${customerDto.id}${GlobalRouter.pathFotoPicture}/${pictureDto.id}";
context.go(uri);
setState(() {
_dto = _customerController.get(id: widget.customerId);
@@ -337,8 +345,43 @@ class _CustomerWidgetState extends State<CustomerWidget> {
}
Future<void> _actionDownload(BuildContext context, CustomerDto customerDto, PictureDto? pictureDto) async {
final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id);
final fileName = pictureDto != null ? '${customerDto.customerNumber}_${pictureDto.id}.pdf' : '${customerDto.customerNumber}.pdf';
await downloadFile(bytes, fileName);
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Dialog(
backgroundColor: Colors.white,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Text(
AppLocalizations.of(context)!.customerWidgetDownloadInProgress,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16,
),
),
],
),
),
),
);
try {
final bytes = await _customerController.export(customerId: customerDto.id, pictureId: pictureDto?.id);
final fileName = pictureDto != null ? '${customerDto.customerNumber}_${pictureDto.id}.pdf' : '${customerDto.customerNumber}.pdf';
if (context.mounted) {
Navigator.of(context).pop();
}
await downloadFile(bytes, fileName);
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop();
}
rethrow;
}
}
}

View File

@@ -3,10 +3,10 @@ import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class PictureDeleteDialog extends StatelessWidget {
class FotoPictureDeleteDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
const PictureDeleteDialog({super.key});
const FotoPictureDeleteDialog({super.key});
@override
Widget build(BuildContext context) {

View File

@@ -4,12 +4,12 @@ import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class PictureFullscreenDialog extends StatelessWidget {
class FotoPictureFullscreenDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
final PictureDto dto;
const PictureFullscreenDialog({super.key, required this.dto});
const FotoPictureFullscreenDialog({super.key, required this.dto});
@override
Widget build(BuildContext context) {

View File

@@ -1,12 +1,12 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/customer/picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
@@ -15,18 +15,18 @@ import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:intl/intl.dart';
class PictureWidget extends StatefulWidget {
class FotoPictureWidget extends StatefulWidget {
final int customerId;
final int pictureId;
const PictureWidget({super.key, required this.customerId, required this.pictureId});
const FotoPictureWidget({super.key, required this.customerId, required this.pictureId});
@override
State<PictureWidget> createState() => _PictureWidgetState();
State<FotoPictureWidget> createState() => _FotoPictureWidgetState();
}
class _PictureWidgetState extends State<PictureWidget> {
class _FotoPictureWidgetState extends State<FotoPictureWidget> {
GeneralStyle get _generalStyle => DiContainer.get();
CustomerController get _customerController => DiContainer.get();
FotoCustomerController get _customerController => DiContainer.get();
PictureController get _pictureController => DiContainer.get();
late CustomerDto _customerDto;
@@ -43,6 +43,14 @@ class _PictureWidgetState extends State<PictureWidget> {
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant FotoPictureWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId || oldWidget.pictureId != widget.pictureId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
void dispose() {
_commentScrollController.dispose();
@@ -72,13 +80,13 @@ class _PictureWidgetState extends State<PictureWidget> {
if (snapshot.hasData) {
CustomerDto? dto = snapshot.data;
if (dto == null) {
return GeneralErrorWidget(error: "FIXME"); // FIXME: set error text data not found
return GeneralErrorWidget(error: AppLocalizations.of(context)!.customerWidgetNotFound);
}
_customerDto = dto;
_selectedPicture ??= dto.pictures.firstWhere((p) => p.id == widget.pictureId);
_selectedPicture ??= _customerDto.pictures.firstOrNull;
if (_selectedPicture == null) {
return GeneralErrorWidget(error: "FIXME"); // FIXME: set error text data not found
return GeneralErrorWidget(error: AppLocalizations.of(context)!.pictureWidgetNotFound);
}
return _body(context, _selectedPicture!);
} else if (snapshot.hasError) {
@@ -97,7 +105,7 @@ class _PictureWidgetState extends State<PictureWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: _customerDto.name),
CustomerBackButton(path: GlobalRouter.pathCustomer),
CustomerBackButton(path: GlobalRouter.pathFotoCustomer),
const SizedBox(height: 24),
Expanded(
child: _mainWidget(context, selectedPicture),
@@ -415,7 +423,7 @@ class _PictureWidgetState extends State<PictureWidget> {
showDialog(
context: context,
builder: (BuildContext context) {
return PictureFullscreenDialog(dto: dto);
return FotoPictureFullscreenDialog(dto: dto);
},
);
}

View File

@@ -10,14 +10,14 @@ import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
class LoginWidget extends StatefulWidget {
const LoginWidget({super.key});
class FotoLoginWidget extends StatefulWidget {
const FotoLoginWidget({super.key});
@override
State<LoginWidget> createState() => _LoginWidgetState();
State<FotoLoginWidget> createState() => _FotoLoginWidgetState();
}
class _LoginWidgetState extends State<LoginWidget> {
class _FotoLoginWidgetState extends State<FotoLoginWidget> {
LoginController get _loginController => DiContainer.get();
LoginCredentials get _loginCredentials => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
@@ -201,7 +201,9 @@ class _LoginWidgetState extends State<LoginWidget> {
_loginCredentials.setLoggedIn(true);
if (context.mounted) {
context.go("/");
// Get the redirect URL from query parameters
final redirect = GoRouterState.of(context).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
}
}
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/search_bar_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class QuestionaireCustomerListWidget extends StatefulWidget {
const QuestionaireCustomerListWidget({super.key});
@override
State<QuestionaireCustomerListWidget> createState() => _QuestionaireCustomerListWidgetState();
}
class _QuestionaireCustomerListWidgetState extends State<QuestionaireCustomerListWidget> {
QuestionnaireCustomerController get _questionnaireCustomerController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
final _searchController = TextEditingController();
late Future<List<QuestionnaireCustomerListDto>> _dtos;
String? _selectedLetter;
late DateFormat _dateFormat;
@override
void initState() {
super.initState();
_dateFormat = DateFormat('dd MMMM yyyy');
_dtos = _questionnaireCustomerController.getAll(_searchController.text, "");
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _body(context),
);
}
Widget _body(BuildContext context) {
return Container(
color: _generalStyle.pageBackgroundColor,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 50.0, right: 50.0, bottom: 8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: "FRAGENBOGEN AUSWERTUNG"),
//_abcHeaderBar(),
const SizedBox(width: 48),
SearchBarWidget(
searchController: _searchController,
onSearch: (text) async => actionSearch(text),
),
Expanded(
child: _customerListWidget(),
),
],
),
),
);
}
Widget _customerListWidget() {
return FutureBuilder<List<QuestionnaireCustomerListDto>>(
future: _dtos,
builder: (BuildContext context, AsyncSnapshot<List<QuestionnaireCustomerListDto>> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const WaitingWidget();
}
if (snapshot.hasData) {
List<QuestionnaireCustomerListDto> dtos = snapshot.data ?? [];
return _listWidget(dtos);
} else if (snapshot.hasError) {
var error = snapshot.error;
return (error is ServerError) ? GeneralErrorWidget.fromServerError(error) : GeneralErrorWidget(error: snapshot.error.toString());
}
return const WaitingWidget();
},
);
}
Widget _listWidget(List<QuestionnaireCustomerListDto> dtos) {
if (dtos.isEmpty) {
return Text(AppLocalizations.of(context)!.customerListEmpty,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
));
}
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_tableHeaderRow(context),
Divider(thickness: 2, height: 1, color: _generalStyle.secondaryWidgetBackgroundColor),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(0),
itemCount: dtos.length,
itemBuilder: (BuildContext context, int index) {
return _tableDataRow(context, dtos[index]);
},
separatorBuilder: (BuildContext context, int index) => Divider(
color: _generalStyle.secondaryWidgetBackgroundColor,
),
),
),
],
),
);
}
Widget _tableHeaderRow(BuildContext context) {
final headerStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 8.0,
children: [
const SizedBox(width: 48),
Expanded(
flex: 1,
child: Text(
AppLocalizations.of(context)!.customerListHeaderCustomerNumber,
style: headerStyle,
),
),
Expanded(
flex: 3,
child: Text(
AppLocalizations.of(context)!.customerListHeaderName,
style: headerStyle,
),
),
Expanded(
flex: 1,
child: Text(
"PLZ/Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Text(
"Ort",
style: headerStyle,
),
),
Expanded(
flex: 2,
child: Wrap(
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(Icons.calendar_month, color: _generalStyle.secondaryWidgetBackgroundColor),
),
Text(
AppLocalizations.of(context)!.customerListHeaderLastDate,
style: headerStyle,
),
],
),
),
],
),
);
}
Widget _tableDataRow(BuildContext context, QuestionnaireCustomerListDto dto) {
final dataStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16.0,
color: _generalStyle.secondaryTextLabelColor,
);
final dateStr = dto.lastUpdateDate == null ? "" : _dateFormat.format(dto.lastUpdateDate!);
return InkWell(
onTap: () => _actionSelect(context, dto),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
spacing: 8.0,
children: [
SizedBox(
width: 48,
child: Icon(Icons.folder_open_outlined, color: _generalStyle.secondaryWidgetBackgroundColor),
),
Expanded(
flex: 1,
child: Text(dto.customerNumber, style: dataStyle),
),
Expanded(
flex: 3,
child: Text(dto.name, style: dataStyle),
),
Expanded(
flex: 1,
child: Text(dto.zip ?? "", style: dataStyle),
),
Expanded(
flex: 2,
child: Text(dto.city ?? "", style: dataStyle),
),
Expanded(
flex: 2,
child: Text(dateStr, style: dataStyle),
),
],
),
),
);
}
Future<void> actionSearch(String text) async {
_reloadData();
}
Future<void> _actionSelect(BuildContext context, QuestionnaireCustomerListDto dto) async {
String uri = "${GlobalRouter.pathQuestionnaireHome}/${GlobalRouter.pathQuestionnaireCustomer}/${dto.id}";
context.go(uri);
}
void _reloadData() {
_dtos = _questionnaireCustomerController.getAll(_searchController.text, _selectedLetter ?? "");
setState(() {});
}
/*
Widget _abcHeaderBar() {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: letters.split('').map((letter) {
final isSelected = _selectedLetter == letter;
return InkWell(
onTap: () {
setState(() {
_selectedLetter = isSelected ? null : letter;
_reloadData();
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSelected ? _generalStyle.secondaryWidgetBackgroundColor : Colors.white,
borderRadius: BorderRadius.circular(4),
),
child: Text(
letter,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20.0,
color: isSelected ? Colors.white : _generalStyle.secondaryWidgetBackgroundColor,
),
),
),
);
}).toList(),
),
);
}*/
}

View File

@@ -0,0 +1,380 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_delete_dialog.dart';
import 'package:fotodocumentation/pages/ui_utils/component/customer_back_button.dart';
import 'package:fotodocumentation/pages/ui_utils/component/general_error_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/page_header_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/component/waiting_widget.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/file_download.dart';
import 'package:intl/intl.dart';
class QuestionaireCustomerWidget extends StatefulWidget {
final int customerId;
const QuestionaireCustomerWidget({super.key, required this.customerId});
@override
State<QuestionaireCustomerWidget> createState() => _QuestionaireCustomerWidgetState();
}
class _QuestionaireCustomerWidgetState extends State<QuestionaireCustomerWidget> {
QuestionnaireCustomerController get _customerController => DiContainer.get();
QuestionnaireController get _pictureController => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
late Future<QuestionnaireCustomerDto?> _dto;
late DateFormat _dateFormat;
@override
void initState() {
super.initState();
_dateFormat = DateFormat('dd MMMM yyyy');
_dto = _customerController.get(id: widget.customerId);
}
@override
void didUpdateWidget(covariant QuestionaireCustomerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.customerId != widget.customerId) {
_dto = _customerController.get(id: widget.customerId);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
color: _generalStyle.pageBackgroundColor,
child: Padding(
padding: const EdgeInsets.only(top: 8.0, left: 50.0, right: 50.0, bottom: 8.0),
child: _body(context),
),
),
);
}
Widget _body(BuildContext context) {
return FutureBuilder<QuestionnaireCustomerDto?>(
future: _dto,
builder: (BuildContext context, AsyncSnapshot<QuestionnaireCustomerDto?> snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const WaitingWidget();
}
if (snapshot.hasData) {
QuestionnaireCustomerDto? dto = snapshot.data;
if (dto == null) {
return Text(
AppLocalizations.of(context)!.customerWidgetNotFound,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
),
);
}
return _mainWidget(dto);
} else if (snapshot.hasError) {
var error = snapshot.error;
return (error is ServerError) ? GeneralErrorWidget.fromServerError(error) : GeneralErrorWidget(error: snapshot.error.toString());
}
return const WaitingWidget();
},
);
}
Widget _mainWidget(QuestionnaireCustomerDto dto) {
var subText = AppLocalizations.of(context)!.customerWidgetCustomerNumberPrefix(dto.customerNumber);
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeaderWidget(text: dto.name, subText: subText),
CustomerBackButton(path: GlobalRouter.pathFotoHome),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_downloadButton(context, dto),
],
),
const SizedBox(height: 24),
Expanded(
child: _customerWidget(dto),
),
],
);
}
Widget _customerWidget(QuestionnaireCustomerDto customerDto) {
var questionnaireDtos = customerDto.questionnaires;
questionnaireDtos.sort((a, b) => b.questionnaireDate.compareTo(a.questionnaireDate));
return Card(
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
_tableHeaderRow(context),
Divider(thickness: 2, height: 1, color: _generalStyle.secondaryWidgetBackgroundColor),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.only(top: 8.0),
itemCount: questionnaireDtos.length,
itemBuilder: (BuildContext context, int index) {
return _tableDataRow(context, customerDto, questionnaireDtos[index]);
},
separatorBuilder: (BuildContext context, int index) => Divider(color: _generalStyle.secondaryWidgetBackgroundColor),
),
),
],
),
);
}
Widget _tableHeaderRow(BuildContext context) {
final headerStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 20,
color: _generalStyle.secondaryWidgetBackgroundColor,
);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8.0,
children: [
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderFoto,
style: headerStyle,
),
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderComment,
style: headerStyle,
),
),
),
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'Bewertung',
style: headerStyle,
),
),
),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
AppLocalizations.of(context)!.customerWidgetHeaderUploadDate,
style: headerStyle,
),
),
),
const SizedBox(width: 96),
],
),
);
}
Widget _tableDataRow(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto questionnaireDto) {
final dataStyle = TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16.0,
color: _generalStyle.secondaryTextLabelColor,
);
final dateStr = _dateFormat.format(questionnaireDto.questionnaireDate);
final evaluationColor = _generalStyle.evaluationColor(value: questionnaireDto.evaluation);
return InkWell(
key: Key("table_row_${customerDto.id}"),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 8.0,
children: [
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 70, maxHeight: 70),
child:
Icon(Icons.abc),
/*
FIXME: remove me
Image.network(
headers: {cred.name: cred.value},
pictureDto.thumbnailSizeUrl,
fit: BoxFit.contain,
),*/
),
),
),
Expanded(
flex: 3,
child: Align(
alignment: Alignment.centerLeft,
child: Text(questionnaireDto.comment ?? "", style: dataStyle),
),
),
Expanded(
flex: 1,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: evaluationColor,
shape: BoxShape.circle,
),
),
),
),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: Text(dateStr, style: dataStyle),
),
),
SizedBox(
width: 48,
child: IconButton(
key: Key("table_row_download_${questionnaireDto.id}"),
icon: Icon(
Icons.file_download_outlined,
color: _generalStyle.loginFormTextLabelColor,
),
onPressed: () => _actionDownload(context, customerDto, questionnaireDto),
),
),
SizedBox(
width: 48,
child: IconButton(
key: Key("table_row_delete_${customerDto.id}"),
icon: Icon(
Icons.delete_outline,
color: _generalStyle.errorColor,
),
onPressed: () => _actionDelete(context, customerDto, questionnaireDto),
),
),
],
),
),
);
}
Widget _downloadButton(BuildContext context, QuestionnaireCustomerDto customerDto) {
return ElevatedButton.icon(
key: Key("download_all_button"),
onPressed: () => _actionDownload(context, customerDto, null),
iconAlignment: IconAlignment.end,
icon: Icon(
Icons.file_download_outlined,
color: _generalStyle.primaryButtonTextColor,
size: 24,
),
label: Text(
"Alle herunterladen",
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontWeight: FontWeight.bold,
fontSize: 16,
color: _generalStyle.primaryButtonTextColor,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.secondaryWidgetBackgroundColor,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: const StadiumBorder(),
),
);
}
Future<void> _actionDelete(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto questionnaireDto) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return QuestionaireDeleteDialog();
},
);
if (confirmed == true) {
_pictureController.delete(questionnaireDto);
setState(() {
_dto = _customerController.get(id: widget.customerId);
});
}
}
Future<void> _actionDownload(BuildContext context, QuestionnaireCustomerDto customerDto, QuestionnaireDto? questionnaireDto) async {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Dialog(
backgroundColor: Colors.white,
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Text(
AppLocalizations.of(context)!.customerWidgetDownloadInProgress,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
fontSize: 16,
),
),
],
),
),
),
);
try {
final bytes = await _customerController.export(customerId: customerDto.id, questionnaireId: questionnaireDto?.id);
final fileName = questionnaireDto != null ? '${customerDto.customerNumber}_${questionnaireDto.id}.pdf' : '${customerDto.customerNumber}.pdf';
if (context.mounted) {
Navigator.of(context).pop();
}
await downloadFile(bytes, fileName);
} catch (e) {
if (context.mounted) {
Navigator.of(context).pop();
}
rethrow;
}
}
}

View File

@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
class QuestionaireDeleteDialog extends StatelessWidget {
GeneralStyle get _generalStyle => DiContainer.get();
const QuestionaireDeleteDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
actionsAlignment: MainAxisAlignment.center,
actionsPadding: EdgeInsets.only(bottom: 50),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(top: 50.0),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _generalStyle.errorColor,
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_outline,
size: 32,
color: Colors.white,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.only(left:50, right: 50, bottom: 50),
child: Text(
AppLocalizations.of(context)!.deleteDialogText,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: _generalStyle.secondaryTextLabelColor,
fontSize: 28.71,
fontWeight: FontWeight.normal,
),
),
),
],
),
actions: [
ElevatedButton(
key: Key("questionnaire_delete_no"),
onPressed: () => Navigator.of(context).pop(false),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.deleteCancelButtonBackgroundColor,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
child: Text(
AppLocalizations.of(context)!.deleteDialogButtonCancel,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: _generalStyle.deleteCancelTextColor,
fontWeight: FontWeight.bold,
fontSize: 18.37,
),
),
),
ElevatedButton(
key: Key("questionnaire_delete_yes"),
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.primaryButtonBackgroundColor,
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
child: Text(
AppLocalizations.of(context)!.deleteDialogButtonApprove,
style: TextStyle(
fontFamily: _generalStyle.fontFamily,
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 18.37,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
class QuestionaireLoginWidget extends StatefulWidget {
const QuestionaireLoginWidget({super.key});
@override
State<QuestionaireLoginWidget> createState() => _QuestionaireLoginWidgetState();
}
class _QuestionaireLoginWidgetState extends State<QuestionaireLoginWidget> {
LoginController get _loginController => DiContainer.get();
LoginCredentials get _loginCredentials => DiContainer.get();
GeneralStyle get _generalStyle => DiContainer.get();
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
String? _error;
final FocusNode _focusNode = FocusNode();
@override
void dispose() {
_focusNode.dispose();
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: _body(context),
);
}
Widget _body(BuildContext context) {
return Container(
color: _generalStyle.pageBackgroundColor,
child: _content(context),
);
}
Widget _content(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20.0),
child: Form(
key: _formKey,
child: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: (event) {
if (event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter) {
_actionSubmit(context);
}
},
child: ListView(
children: [
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40.0),
child: Image.asset(
'assets/images/logo.png',
height: 120,
),
),
),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Card(
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(70.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Text(
key: Key("login_title"),
AppLocalizations.of(context)!.questionnaireLoginTitle,
style: TextStyle(
fontWeight: FontWeight.bold,
color: _generalStyle.primaryTextLabelColor,
fontFamily: _generalStyle.fontFamily,
fontSize: 50,
),
),
),
const SizedBox(height: 40),
if (_error != null) ...[
_errorWidget(),
const SizedBox(height: 40),
],
TextFormField(
key: Key("username"),
controller: _usernameController,
decoration: _formFieldInputDecoration(AppLocalizations.of(context)!.loginUsernameTFLabel),
),
const SizedBox(height: 25),
TextFormField(
key: Key("password"),
controller: _passwordController,
obscureText: true,
decoration: _formFieldInputDecoration(AppLocalizations.of(context)!.loginPasswordTFLabel),
),
const SizedBox(height: 25),
Center(
child: ElevatedButton(
key: Key("SubmitWidgetButton"),
style: ElevatedButton.styleFrom(
backgroundColor: _generalStyle.primaryButtonBackgroundColor,
foregroundColor: _generalStyle.primaryButtonTextColor,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
),
onPressed: () async => await _actionSubmit(context),
child: Text(
AppLocalizations.of(context)!.loginLoginButtonLabel,
style: TextStyle(fontWeight: FontWeight.bold, fontFamily: _generalStyle.fontFamily),
),
),
),
const SizedBox(height: 30),
],
),
),
),
),
),
],
),
),
),
);
}
Widget _errorWidget() {
return Center(
child: Text(
_error!,
style: TextStyle(
color: _generalStyle.errorColor,
fontWeight: FontWeight.bold,
fontSize: 16,
fontFamily: _generalStyle.fontFamily,
),
),
);
}
InputDecoration _formFieldInputDecoration(String text) {
var formLabelTextStyle = TextStyle(
color: _generalStyle.loginFormTextLabelColor,
fontFamily: _generalStyle.fontFamily,
fontSize: 14,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.normal,
);
return InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: _error != null ? BorderSide(color: _generalStyle.errorColor) : BorderSide(),
),
enabledBorder: _error != null
? OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: _generalStyle.errorColor),
)
: null,
labelText: text.toUpperCase(),
labelStyle: formLabelTextStyle,
floatingLabelBehavior: FloatingLabelBehavior.always,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
);
}
Future<void> _actionSubmit(BuildContext context) async {
String username = _usernameController.text;
String password = _passwordController.text;
AuthenticateReply authenticateReply = await _loginController.authenticate(username, password);
JwtTokenPairDto? jwtTokenPairDto = authenticateReply.jwtTokenPairDto;
if (jwtTokenPairDto == null) {
setState(() => _error = AppLocalizations.of(context)!.loginErrorMessage);
return;
}
_loginCredentials.setLoggedIn(true);
if (context.mounted) {
// Get the redirect URL from query parameters
final redirect = GoRouterState.of(context).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
}
}
}

View File

@@ -94,7 +94,7 @@ class PageHeaderWidget extends StatelessWidget {
return ElevatedButton.icon(
onPressed: () {
loginCredentials.logout();
context.go(GlobalRouter.pathLogin);
context.go(GlobalRouter.pathFotoLogin);
},
icon: Icon(
Icons.logout_rounded,

View File

@@ -1,6 +1,8 @@
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/pages/ui_utils/general_style.dart';
import 'package:fotodocumentation/utils/http_client_utils.dart';
import 'package:fotodocumentation/utils/jwt_token_storage.dart';
@@ -24,8 +26,11 @@ class DiContainer {
DiContainer.instance.put(HttpClientUtils, HttpCLientUtilsImpl());
DiContainer.instance.put(UrlUtils, UrlUtilsImpl());
DiContainer.instance.put(LoginController, LoginControllerImpl());
DiContainer.instance.put(CustomerController, CustomerControllerImpl());
DiContainer.instance.put(FotoCustomerController, FotoCustomerControllerImpl());
DiContainer.instance.put(PictureController, PictureControllerImpl());
DiContainer.instance.put(QuestionnaireCustomerController, QuestionnaireCustomerControllerImpl());
DiContainer.instance.put(QuestionnaireController, QuestionnaireControllerImpl());
}
void put<T>(Type key, T object) {

View File

@@ -1,26 +1,35 @@
// needed for web horizontal scroll behavior
import 'package:flutter/material.dart';
import 'package:fotodocumentation/main.dart';
import 'package:fotodocumentation/pages/customer/customer_list_widget.dart';
import 'package:fotodocumentation/pages/customer/customer_widget.dart';
import 'package:fotodocumentation/pages/customer/picture_widget.dart';
import 'package:fotodocumentation/pages/login/login_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_customer_list_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_customer_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_widget.dart';
import 'package:fotodocumentation/pages/foto/login/foto_login_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_customer_list_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_customer_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/login/questionnaire_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:go_router/go_router.dart';
class GlobalRouter {
static final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'root');
static final GlobalKey<NavigatorState> bottomBarNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'bottombar');
static final GlobalKey<NavigatorState> adminNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'admin');
static final GlobalKey<NavigatorState> skillEditorNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'skillEditor');
static final String pathHome = "/home";
static final String pathCustomer = "/customer";
static final String pathPicture = "/picture";
static final String pathLogin = "/login";
static final String pathRoot = "/";
static final GoRouter router = createRouter(pathHome);
static final String pathFoto = "/foto";
static final String pathQuestionnaire = "/fragenbogen";
static final String pathFotoHome = "$pathFoto/home";
static final String pathFotoCustomer = "$pathFoto/customer";
static final String pathFotoPicture = "$pathFoto/picture";
static final String pathFotoLogin = "$pathFoto/login";
static final String pathQuestionnaireHome = "$pathQuestionnaire/home";
static final String pathQuestionnaireCustomer = "customer";
static final String pathQuestionnaireLogin = "$pathQuestionnaire/login";
static final GoRouter router = createRouter(pathRoot);
static GoRouter createRouter(String initialLocation) {
return GoRouter(
@@ -28,40 +37,80 @@ class GlobalRouter {
initialLocation: initialLocation,
routes: <RouteBase>[
GoRoute(
path: "/",
redirect: (_, __) => pathHome,
path: "/",
redirect: (_, __) {//nvlev4YnTi
logger.t("uri / redirect to $pathFotoHome");
return pathFotoHome;
}),
GoRoute(
path: pathFoto,
redirect: (_, __) {
logger.t("uri $pathFoto redirect to $pathFotoHome");
return pathFotoHome;
}),
GoRoute(
path: pathFotoLogin,
builder: (_, __) {
logger.t("uri $pathFotoLogin show foto login");
return const FotoLoginWidget();
},
),
GoRoute(
path: pathLogin,
builder: (BuildContext context, GoRouterState state) => const LoginWidget(),
),
GoRoute(
path: pathHome,
builder: (context, state) => CustomerListWidget(),
path: pathFotoHome,
builder: (context, state) => FotoCustomerListWidget(),
routes: [
GoRoute(
path: "$pathCustomer/:customerId",
path: "$pathFotoCustomer/:customerId",
builder: (context, state) {
var idStr = state.pathParameters['customerId'];
var id = idStr == null ? null : int.tryParse(idStr);
return CustomerWidget(customerId: id ?? -1);
return FotoCustomerWidget(customerId: id ?? -1);
},
routes: [
GoRoute(
path: "$pathPicture/:pictureId",
path: "$pathFotoPicture/:pictureId",
builder: (context, state) {
var customerIdStr = state.pathParameters['customerId'];
var customerId = customerIdStr == null ? null : int.tryParse(customerIdStr);
var pictureIdStr = state.pathParameters['pictureId'];
var pictureId = pictureIdStr == null ? null : int.tryParse(pictureIdStr);
return PictureWidget(customerId: customerId ?? -1, pictureId: pictureId ?? -1);
return FotoPictureWidget(customerId: customerId ?? -1, pictureId: pictureId ?? -1);
},
),
],
),
],
),
GoRoute(
path: pathQuestionnaire,
redirect: (_, __) {
logger.t("uri $pathQuestionnaire redirect to $pathQuestionnaireHome");
return pathQuestionnaireHome;
}),
GoRoute(
path: pathQuestionnaireLogin,
builder: (_, __) {
logger.t("uri $pathQuestionnaireLogin show questionnaire login");
return const QuestionaireLoginWidget();
},
),
GoRoute(
path: pathQuestionnaireHome,
builder: (context, state) => QuestionaireCustomerListWidget(),
routes: [
GoRoute(
path: "$pathQuestionnaireCustomer/:customerId",
builder: (context, state) {
var uriStr = state.uri.toString();
logger.t("uri $uriStr show questionnaire");
var idStr = state.pathParameters['customerId'];
var id = idStr == null ? null : int.tryParse(idStr);
return QuestionaireCustomerWidget(customerId: id ?? -1);
},
),
],
),
],
redirect: (context, state) {
var uriStr = state.uri.toString();
@@ -69,7 +118,18 @@ class GlobalRouter {
LoginCredentials loginCredentials = DiContainer.get();
if (!loginCredentials.isLoggedIn) {
return pathLogin;
if (uriStr != '/') {
if (uriStr.startsWith(pathFoto) && !uriStr.startsWith(pathFotoLogin)) {
var url = '$pathFotoLogin?redirect=${Uri.encodeComponent(uriStr)}';
logger.t("foto redirect to $url");
return url;
}
if (uriStr.startsWith(pathQuestionnaire) && !uriStr.startsWith(pathQuestionnaireLogin)) {
var url = '$pathQuestionnaireLogin?redirect=${Uri.encodeComponent(uriStr)}';
logger.t("questionnaire redirect to $url");
return url;
}
}
}
return null;
},

View File

@@ -31,7 +31,7 @@ class HttpClientInterceptor extends http.BaseClient {
// Navigate to login page using GoRouter
final context = GlobalRouter.rootNavigatorKey.currentContext;
if (context != null) {
context.go(GlobalRouter.pathLogin);
context.go(GlobalRouter.pathFotoLogin);
}
}
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/http_client_utils.dart';
@@ -17,7 +17,7 @@ void main() {
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
CustomerController controller = CustomerControllerImpl();
FotoCustomerController controller = FotoCustomerControllerImpl();
group('PictureControllerTest', () {
test('returns a list of customers', () async {

View File

@@ -0,0 +1,124 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/base_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/http_client_utils.dart';
import 'package:fotodocumentation/utils/jwt_token_storage.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/mockito.dart';
import '../testing/test_http_client_utils.dart';
import '../testing/test_utils.mocks.dart';
void main() {
DiContainer.instance.initState();
var jwtTokenStorage = MockJwtTokenStorage();
when(jwtTokenStorage.getAccessToken()).thenAnswer((_) => null);
DiContainer.instance.put(JwtTokenStorage, jwtTokenStorage);
QuestionnaireCustomerController controller = QuestionnaireCustomerControllerImpl();
group('PictureControllerTest', () {
test('returns a list of customers', () async {
final client = MockClient();
DiContainer.instance.put(HttpClientUtils, TestHttpCLientUtilsImpl(client));
when(client.get(Uri.parse('http://localhost:8080/api/questionnairecustomer?query=&startsWith='), headers: {"Accept-Language": "en-US"})).thenAnswer((_) async => http.Response(_customersJson, 200));
var dtos = await controller.getAll("", "");
expect(dtos, isA<List<QuestionnaireCustomerListDto>>());
expect(dtos.length, 3);
});
test('throws an exception if the http call completes with an error', () async {
final client = MockClient();
DiContainer.instance.put(HttpClientUtils, TestHttpCLientUtilsImpl(client));
when(client.get(Uri.parse('http://localhost:8080/api/questionnairecustomer?query=&startsWith='), headers: {"Accept-Language": "en-US"})).thenAnswer((_) async => http.Response('Not Found', 404));
expect(() async => await controller.getAll("", ""), throwsA(isA<ServerError>()));
});
});
test('returns a customer', () async {
final client = MockClient();
DiContainer.instance.put(HttpClientUtils, TestHttpCLientUtilsImpl(client));
when(client.get(Uri.parse('http://localhost:8080/api/questionnairecustomer/4'), headers: {"Accept-Language": "en-US"}))
.thenAnswer((_) async => http.Response(_customerJson, 200));
var dto = await controller.get(id: 4);
expect(dto, isA<QuestionnaireCustomerDto>());
});
test('export a customer', () async {
final client = MockClient();
DiContainer.instance.put(HttpClientUtils, TestHttpCLientUtilsImpl(client));
when(client.get(Uri.parse('http://localhost:8080/api/questionnairecustomer/export/4'), headers: {"Accept-Language": "en-US"}))
.thenAnswer((_) async => http.Response(_customerJson, 200));
var dto = await controller.export(customerId: 4);
expect(dto, isA<List<int>>());
});
test('export a customer picture', () async {
final client = MockClient();
DiContainer.instance.put(HttpClientUtils, TestHttpCLientUtilsImpl(client));
when(client.get(Uri.parse('http://localhost:8080/api/questionnairecustomer/export/4?questionnaire=1'), headers: {"Accept-Language": "en-US"}))
.thenAnswer((_) async => http.Response(_customerJson, 200));
var dto = await controller.export(customerId: 4, questionnaireId: 1);
expect(dto, isA<List<int>>());
});
}
String _customersJson = '''[
{
"id": 1,
"name": "Müller Apotheke",
"customerNumber": "1234",
"lastUpdateDate": 1729764570000
},
{
"id": 2,
"name": "Meier Apotheke",
"customerNumber": "2345",
"lastUpdateDate": 1729764570000
},
{
"id": 3,
"name": "Schmidt Apotheke",
"customerNumber": "3456",
"lastUpdateDate": 1729764570000
}
]''';
String _customerJson = '''{
"id": 1,
"name": "Müller Apotheke",
"customerNumber": "1234",
"city": "Hannover",
"zip": "12345",
"questionnaires": [
{
"id": 1,
"comment": "good looking picture 1",
"category": null,
"questionnaireDate": 1767262170000,
"username": "verboomp",
"evaluation": 1
},
{
"id": 2,
"comment": "good looking picture 2",
"category": null,
"questionnaireDate": 1767348570000,
"username": "verboomp",
"evaluation": 1
}
]
}''';

View File

@@ -6,12 +6,12 @@ import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -30,13 +30,13 @@ Future<void> _searchtest(WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
String searchText = 'Henk';
var controller = MockCustomerController();
DiContainer.instance.put(CustomerController, controller);
var controller = MockFotoCustomerController();
DiContainer.instance.put(FotoCustomerController, controller);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
when(controller.getAll(searchText, "")).thenAnswer((_) async => [_list.first]);
await pumpAppConfig(tester, GlobalRouter.pathHome);
await pumpAppConfig(tester, GlobalRouter.pathFotoHome);
verify(controller.getAll(argThat(equals("")), argThat(equals("")))).called(1);
await tester.enterText(find.byKey(Key("Search_text_field")), searchText);

View File

@@ -2,20 +2,20 @@ import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/customer/picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/customer/picture_widget.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_delete_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_widget.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:flutter_image_test_utils/flutter_image_test_utils.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -25,15 +25,15 @@ void main() {
testWidgets('Customer screen', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockCustomerController();
var controller = MockFotoCustomerController();
var pictureController = MockPictureController();
DiContainer.instance.put(PictureController, pictureController);
DiContainer.instance.put(CustomerController, controller);
DiContainer.instance.put(FotoCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -41,24 +41,24 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureWidget), findsOneWidget);
expect(find.byType(FotoPictureWidget), findsOneWidget);
});
});
testWidgets('Customer delete yes', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockCustomerController();
var controller = MockFotoCustomerController();
var pictureController = MockPictureController();
DiContainer.instance.put(PictureController, pictureController);
DiContainer.instance.put(CustomerController, controller);
DiContainer.instance.put(FotoCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
when(pictureController.delete(argThat(isA<PictureDto>()))).thenAnswer((_) async => true);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -66,7 +66,7 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureDeleteDialog), findsOneWidget);
expect(find.byType(FotoPictureDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("picture_delete_yes")));
@@ -80,16 +80,16 @@ void main() {
testWidgets('Customer delete no', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockCustomerController();
var controller = MockFotoCustomerController();
var pictureController = MockPictureController();
DiContainer.instance.put(PictureController, pictureController);
DiContainer.instance.put(CustomerController, controller);
DiContainer.instance.put(FotoCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
@@ -97,7 +97,7 @@ void main() {
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(PictureDeleteDialog), findsOneWidget);
expect(find.byType(FotoPictureDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("picture_delete_no")));

View File

@@ -1,17 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_image_test_utils/image_test/image_test_io.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/dto/customer_dto.dart' show CustomerDto, CustomerListDto;
import 'package:fotodocumentation/dto/picture_dto.dart';
import 'package:fotodocumentation/pages/customer/picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/pages/foto/customer/foto_picture_fullscreen_dialog.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -22,7 +22,7 @@ void main() {
late PictureDto pictureDto1;
late PictureDto pictureDto2;
late PictureDto pictureDto3;
late MockCustomerController mockCustomerController;
late MockFotoCustomerController mockCustomerController;
setUp(() {
pictureDto1 = PictureDto(
@@ -68,8 +68,8 @@ void main() {
pictures: [pictureDto1, pictureDto2, pictureDto3],
);
mockCustomerController = MockCustomerController();
DiContainer.instance.put(CustomerController, mockCustomerController);
mockCustomerController = MockFotoCustomerController();
DiContainer.instance.put(FotoCustomerController, mockCustomerController);
});
group('PictureWidget', () {
@@ -80,7 +80,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify customer name is displayed
@@ -102,7 +102,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify the comment is displayed
@@ -116,8 +116,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await tester.pump(Duration(seconds: 10));
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// The comment field should be empty but the label should exist
@@ -132,7 +131,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should always be visible at the bottom
@@ -147,7 +146,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be visible
@@ -162,7 +161,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be visible (right one is disabled)
@@ -177,7 +176,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Verify first picture comment is shown
@@ -200,7 +199,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/2");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/2");
await tester.pumpAndSettle();
// Verify second picture comment is shown
@@ -230,7 +229,7 @@ void main() {
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Both navigation buttons should be shown but disabled
@@ -245,7 +244,7 @@ void main() {
when(mockCustomerController.get(id: 1)).thenAnswer((_) async => customerDto);
when(mockCustomerController.getAll("", "")).thenAnswer((_) async => _list);
provideMockedNetworkImages(() async {
await pumpAppConfig(tester, "${GlobalRouter.pathHome}${GlobalRouter.pathCustomer}/1${GlobalRouter.pathPicture}/1");
await pumpAppConfig(tester, "${GlobalRouter.pathFotoHome}${GlobalRouter.pathFotoCustomer}/1${GlobalRouter.pathFotoPicture}/1");
await tester.pumpAndSettle();
// Find the GestureDetector with the image key
@@ -262,7 +261,7 @@ void main() {
await tester.pumpAndSettle();
// Verify fullscreen dialog is shown
expect(find.byType(PictureFullscreenDialog), findsOneWidget);
expect(find.byType(FotoPictureFullscreenDialog), findsOneWidget);
});
});
});

View File

@@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/pages/login/login_widget.dart';
import 'package:fotodocumentation/pages/foto/login/foto_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../testing/test_utils.dart';
import '../testing/test_utils.mocks.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -32,7 +32,7 @@ void main() {
testWidgets('displays login title', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify the login title is displayed (German localization)
@@ -42,7 +42,7 @@ void main() {
testWidgets('displays username and password fields', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify username field exists
@@ -55,7 +55,7 @@ void main() {
testWidgets('displays login button', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Verify login button exists
@@ -65,7 +65,7 @@ void main() {
testWidgets('can enter username and password', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Enter username
@@ -91,7 +91,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -118,7 +118,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -140,7 +140,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpAppConfig(tester, GlobalRouter.pathLogin);
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
@@ -162,7 +162,7 @@ void main() {
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Enter credentials
@@ -181,7 +181,7 @@ void main() {
testWidgets('password field obscures text', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const LoginWidget());
await pumpApp(tester, const FotoLoginWidget());
await tester.pumpAndSettle();
// Find the EditableText descendant of the password field

View File

@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart';
import 'package:fotodocumentation/dto/questionnaire_dto.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_customer_widget.dart';
import 'package:fotodocumentation/pages/questionnaire/customer/questionnaire_delete_dialog.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
DiContainer.instance.initState();
DiContainer.instance.put(LoginCredentials, getDefaultLoginCredentials());
group('Customer Test', () {
testWidgets('Customer screen', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockQuestionnaireCustomerController();
var questionnaireController = MockQuestionnaireController();
DiContainer.instance.put(QuestionnaireController, questionnaireController);
DiContainer.instance.put(QuestionnaireCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
await pumpAppConfig(tester, "${GlobalRouter.pathQuestionnaireHome}/${GlobalRouter.pathQuestionnaireCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
await tester.tap(find.byKey(Key("table_row_1")).first);
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(QuestionaireCustomerWidget), findsOneWidget);
});
testWidgets('Customer delete yes', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockQuestionnaireCustomerController();
var questionnaireController = MockQuestionnaireController();
DiContainer.instance.put(QuestionnaireController, questionnaireController);
DiContainer.instance.put(QuestionnaireCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
when(questionnaireController.delete(argThat(isA<QuestionnaireDto>()))).thenAnswer((_) async => true);
await pumpAppConfig(tester, "${GlobalRouter.pathQuestionnaireHome}/${GlobalRouter.pathQuestionnaireCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
await tester.tap(find.byKey(Key("table_row_delete_1")).first);
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(QuestionaireDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("questionnaire_delete_yes")));
await tester.pumpAndSettle();
// Verify that the delete method was called on the picture controller
verify(questionnaireController.delete(argThat(isA<QuestionnaireDto>()))).called(1);
});
testWidgets('Customer delete no', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
var controller = MockQuestionnaireCustomerController();
var questionnaireController = MockQuestionnaireController();
DiContainer.instance.put(QuestionnaireController, questionnaireController);
DiContainer.instance.put(QuestionnaireCustomerController, controller);
when(controller.get(id: 1)).thenAnswer((_) async => _dto);
when(controller.getAll("", "")).thenAnswer((_) async => _list);
await pumpAppConfig(tester, "${GlobalRouter.pathQuestionnaireHome}/${GlobalRouter.pathQuestionnaireCustomer}/1");
verify(controller.get(id: 1)).called(1);
// Click on the first row (InkWell) to open the picture popup
await tester.tap(find.byKey(Key("table_row_delete_1")).first);
await tester.pumpAndSettle();
// Verify that the popup is shown by checking for the PictureWidget
expect(find.byType(QuestionaireDeleteDialog), findsOneWidget);
// Click the yes button to confirm delete
await tester.tap(find.byKey(Key("questionnaire_delete_no")));
await tester.pumpAndSettle();
// Verify that the delete method was called on the picture controller
verifyNever(questionnaireController.delete(argThat(isA<QuestionnaireDto>())));
});
});
}
QuestionnaireCustomerDto _dto = QuestionnaireCustomerDto(
id: 1,
customerNumber: "CODE1",
name: "Customer 1",
questionnaires: [QuestionnaireDto(id: 1, comment: "Some comment", category: "category", questionnaireDate: DateTime.now(), username: "username", evaluation: 1)]);
List<QuestionnaireCustomerListDto> _list = [
QuestionnaireCustomerListDto(id: 1, customerNumber: "CODE1", name: "Customer 1"),
QuestionnaireCustomerListDto(id: 2, customerNumber: "CODE2", name: "Customer 2")
];

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart';
import 'package:fotodocumentation/pages/questionnaire/login/questionnaire_login_widget.dart';
import 'package:fotodocumentation/utils/di_container.dart';
import 'package:fotodocumentation/utils/global_router.dart';
import 'package:fotodocumentation/utils/login_credentials.dart';
import 'package:mockito/mockito.dart';
import '../../../testing/test_utils.dart';
import '../../../testing/test_utils.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
DiContainer.instance.initState();
late MockLoginController mockLoginController;
late MockLoginCredentials mockLoginCredentials;
setUp(() {
mockLoginController = MockLoginController();
mockLoginCredentials = MockLoginCredentials();
when(mockLoginCredentials.isLoggedIn).thenReturn(false);
DiContainer.instance.put(LoginController, mockLoginController);
DiContainer.instance.put(LoginCredentials, mockLoginCredentials);
});
group('LoginWidget', () {
testWidgets('displays login title', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify the login title is displayed (German localization)
expect(find.byKey(const Key('login_title')), findsOneWidget);
});
testWidgets('displays username and password fields', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify username field exists
expect(find.byKey(const Key("username")), findsOneWidget);
// Verify password field exists
expect(find.byKey(const Key("password")), findsOneWidget);
});
testWidgets('displays login button', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Verify login button exists
expect(find.byKey(const Key("SubmitWidgetButton")), findsOneWidget);
});
testWidgets('can enter username and password', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Enter username
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.pumpAndSettle();
// Enter password
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Verify text was entered
expect(find.text('testuser'), findsOneWidget);
});
testWidgets('calls authenticate on login button tap', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
final jwtTokenPairDto = JwtTokenPairDto(
accessToken: 'test_access_token',
refreshToken: 'test_refresh_token',
);
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify authenticate was called with correct credentials
verify(mockLoginController.authenticate('testuser', 'testpassword')).called(1);
});
testWidgets('sets logged in on successful authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
final jwtTokenPairDto = JwtTokenPairDto(
accessToken: 'test_access_token',
refreshToken: 'test_refresh_token',
);
when(mockLoginController.authenticate('testuser', 'testpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: jwtTokenPairDto));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'testpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify setLoggedIn was called
verify(mockLoginCredentials.setLoggedIn(true)).called(1);
});
testWidgets('displays error message on failed authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpAppConfig(tester, GlobalRouter.pathFotoLogin);
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'wrongpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify error message is displayed (German localization)
expect(find.text('Falscher Benutzername oder Passwort'), findsOneWidget);
});
testWidgets('does not call setLoggedIn on failed authentication', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
when(mockLoginController.authenticate('testuser', 'wrongpassword'))
.thenAnswer((_) async => (jwtTokenPairDto: null));
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Enter credentials
await tester.enterText(find.byKey(const Key("username")), 'testuser');
await tester.enterText(find.byKey(const Key("password")), 'wrongpassword');
await tester.pumpAndSettle();
// Tap login button
await tester.tap(find.byKey(const Key("SubmitWidgetButton")));
await tester.pumpAndSettle();
// Verify setLoggedIn was NOT called
verifyNever(mockLoginCredentials.setLoggedIn(any));
});
testWidgets('password field obscures text', (WidgetTester tester) async {
setScreenSize(tester, 1024, 1024);
await pumpApp(tester, const QuestionaireLoginWidget());
await tester.pumpAndSettle();
// Find the EditableText descendant of the password field
// TextFormField wraps TextField which contains EditableText
final passwordFieldFinder = find.byKey(const Key("password"));
final editableTextFinder = find.descendant(
of: passwordFieldFinder,
matching: find.byType(EditableText),
);
final editableText = tester.widget<EditableText>(editableTextFinder);
// Verify obscureText is true
expect(editableText.obscureText, isTrue);
});
});
}

View File

@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:fotodocumentation/controller/questionnaire_controller.dart';
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart';
import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:fotodocumentation/controller/customer_controller.dart';
import 'package:fotodocumentation/controller/foto_customer_controller.dart';
import 'package:fotodocumentation/controller/login_controller.dart';
import 'package:fotodocumentation/controller/picture_controller.dart';
import 'package:fotodocumentation/l10n/app_localizations.dart';
@@ -63,9 +65,11 @@ Future<void> pumpAppConfig(WidgetTester tester, String initialLocation) async {
@GenerateMocks([
LoginCredentials,
LoginController,
CustomerController,
FotoCustomerController,
PictureController,
JwtTokenStorage,
QuestionnaireController,
QuestionnaireCustomerController,
http.Client,
])
void main() {}

View File

@@ -4,16 +4,23 @@
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i7;
import 'dart:convert' as _i14;
import 'dart:typed_data' as _i15;
import 'dart:convert' as _i18;
import 'dart:typed_data' as _i19;
import 'dart:ui' as _i5;
import 'package:fotodocumentation/controller/customer_controller.dart' as _i9;
import 'package:fotodocumentation/controller/foto_customer_controller.dart'
as _i9;
import 'package:fotodocumentation/controller/login_controller.dart' as _i6;
import 'package:fotodocumentation/controller/picture_controller.dart' as _i11;
import 'package:fotodocumentation/controller/questionnaire_controller.dart'
as _i14;
import 'package:fotodocumentation/controller/questionnaire_customer_controller.dart'
as _i16;
import 'package:fotodocumentation/dto/customer_dto.dart' as _i10;
import 'package:fotodocumentation/dto/jwt_token_pair_dto.dart' as _i8;
import 'package:fotodocumentation/dto/picture_dto.dart' as _i12;
import 'package:fotodocumentation/dto/questionnaire_customer_dto.dart' as _i17;
import 'package:fotodocumentation/dto/questionnaire_dto.dart' as _i15;
import 'package:fotodocumentation/utils/jwt_token_storage.dart' as _i13;
import 'package:fotodocumentation/utils/login_credentials.dart' as _i3;
import 'package:http/http.dart' as _i2;
@@ -184,12 +191,12 @@ class MockLoginController extends _i1.Mock implements _i6.LoginController {
) as bool);
}
/// A class which mocks [CustomerController].
/// A class which mocks [FotoCustomerController].
///
/// See the documentation for Mockito's code generation for more information.
class MockCustomerController extends _i1.Mock
implements _i9.CustomerController {
MockCustomerController() {
class MockFotoCustomerController extends _i1.Mock
implements _i9.FotoCustomerController {
MockFotoCustomerController() {
_i1.throwOnMissingStub(this);
}
@@ -318,6 +325,90 @@ class MockJwtTokenStorage extends _i1.Mock implements _i13.JwtTokenStorage {
);
}
/// A class which mocks [QuestionnaireController].
///
/// See the documentation for Mockito's code generation for more information.
class MockQuestionnaireController extends _i1.Mock
implements _i14.QuestionnaireController {
MockQuestionnaireController() {
_i1.throwOnMissingStub(this);
}
@override
_i7.Future<bool> delete(_i15.QuestionnaireDto? dto) => (super.noSuchMethod(
Invocation.method(
#delete,
[dto],
),
returnValue: _i7.Future<bool>.value(false),
) as _i7.Future<bool>);
@override
_i7.Future<bool> updateEvaluation(_i15.QuestionnaireDto? dto) =>
(super.noSuchMethod(
Invocation.method(
#updateEvaluation,
[dto],
),
returnValue: _i7.Future<bool>.value(false),
) as _i7.Future<bool>);
}
/// A class which mocks [QuestionnaireCustomerController].
///
/// See the documentation for Mockito's code generation for more information.
class MockQuestionnaireCustomerController extends _i1.Mock
implements _i16.QuestionnaireCustomerController {
MockQuestionnaireCustomerController() {
_i1.throwOnMissingStub(this);
}
@override
_i7.Future<List<_i17.QuestionnaireCustomerListDto>> getAll(
String? query,
String? startsWith,
) =>
(super.noSuchMethod(
Invocation.method(
#getAll,
[
query,
startsWith,
],
),
returnValue: _i7.Future<List<_i17.QuestionnaireCustomerListDto>>.value(
<_i17.QuestionnaireCustomerListDto>[]),
) as _i7.Future<List<_i17.QuestionnaireCustomerListDto>>);
@override
_i7.Future<_i17.QuestionnaireCustomerDto?> get({required int? id}) =>
(super.noSuchMethod(
Invocation.method(
#get,
[],
{#id: id},
),
returnValue: _i7.Future<_i17.QuestionnaireCustomerDto?>.value(),
) as _i7.Future<_i17.QuestionnaireCustomerDto?>);
@override
_i7.Future<List<int>> export({
required int? customerId,
int? questionnaireId,
}) =>
(super.noSuchMethod(
Invocation.method(
#export,
[],
{
#customerId: customerId,
#questionnaireId: questionnaireId,
},
),
returnValue: _i7.Future<List<int>>.value(<int>[]),
) as _i7.Future<List<int>>);
}
/// A class which mocks [Client].
///
/// See the documentation for Mockito's code generation for more information.
@@ -373,7 +464,7 @@ class MockClient extends _i1.Mock implements _i2.Client {
Uri? url, {
Map<String, String>? headers,
Object? body,
_i14.Encoding? encoding,
_i18.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -404,7 +495,7 @@ class MockClient extends _i1.Mock implements _i2.Client {
Uri? url, {
Map<String, String>? headers,
Object? body,
_i14.Encoding? encoding,
_i18.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -435,7 +526,7 @@ class MockClient extends _i1.Mock implements _i2.Client {
Uri? url, {
Map<String, String>? headers,
Object? body,
_i14.Encoding? encoding,
_i18.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -466,7 +557,7 @@ class MockClient extends _i1.Mock implements _i2.Client {
Uri? url, {
Map<String, String>? headers,
Object? body,
_i14.Encoding? encoding,
_i18.Encoding? encoding,
}) =>
(super.noSuchMethod(
Invocation.method(
@@ -514,7 +605,7 @@ class MockClient extends _i1.Mock implements _i2.Client {
) as _i7.Future<String>);
@override
_i7.Future<_i15.Uint8List> readBytes(
_i7.Future<_i19.Uint8List> readBytes(
Uri? url, {
Map<String, String>? headers,
}) =>
@@ -524,8 +615,8 @@ class MockClient extends _i1.Mock implements _i2.Client {
[url],
{#headers: headers},
),
returnValue: _i7.Future<_i15.Uint8List>.value(_i15.Uint8List(0)),
) as _i7.Future<_i15.Uint8List>);
returnValue: _i7.Future<_i19.Uint8List>.value(_i19.Uint8List(0)),
) as _i7.Future<_i19.Uint8List>);
@override
_i7.Future<_i2.StreamedResponse> send(_i2.BaseRequest? request) =>

View File

@@ -20,43 +20,6 @@
<skipDeploy>true</skipDeploy>
</configuration>
</plugin>
<!--
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy_for_release</id>
<phase>validate</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/src/main/webapp</outputDirectory>
<resources>
<resource>
<directory>${basedir}/../../../-frontend/build/web/</directory>
</resource>
</resources>
</configuration>
</execution>
<execution>
<id>copy_for_general</id>
<phase>validate</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${basedir}/src/main/webapp</outputDirectory>
<resources>
<resource>
<directory>${basedir}/../-frontend/build/web/</directory>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
-->
</plugins>
</build>

View File

@@ -9,6 +9,12 @@
<url-pattern>/api/picture</url-pattern>
<url-pattern>/api/picture/*</url-pattern>
<url-pattern>/api/questionnairecustomer</url-pattern>
<url-pattern>/api/questionnairecustomer/*</url-pattern>
<url-pattern>/api/questionnaire</url-pattern>
<url-pattern>/api/questionnaire/*</url-pattern>
<http-method>GET</http-method>
<http-method>POST</http-method>
<http-method>PUT</http-method>

View File

@@ -231,7 +231,7 @@
<version.org.jboss.resteasy>6.2.5.Final</version.org.jboss.resteasy>
<subethamail.version>1.2</subethamail.version>
<version.commons-io>2.7</version.commons-io>
<version.commons-io>2.21.0</version.commons-io>
<version.commons-lang>2.6</version.commons-lang>
<version.commons-lang3>3.5</version.commons-lang3>
<version.commons-logging>1.2</version.commons-logging>
@@ -290,12 +290,13 @@
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.1.0</version>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.1.0</version>
<version>5.5.1</version>
<exclusions>
<exclusion>
<groupId>dom4j</groupId>

316
question_answer.json Normal file
View File

@@ -0,0 +1,316 @@
[
{
"id": "question1",
"title": "",
"order": 1,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Nicht-kaufender Kunde",
"selected": true
},
{
"id": "answer2",
"answer": "Bestandskunde",
"selected": false
}
]
}
},
{
"id": "question2",
"title": "Haben Sie Rezeptpatienten(GKV) für Inko?",
"order": 2,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer1",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question3",
"title": "Warum nicht?",
"order": 3,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Lagergründe",
"selected": false
},
{
"id": "answer2",
"answer": "Wirtschaftlichkeitsgründe",
"selected": true
},
{
"id": "answer3",
"answer": "Administrativer Aufwand",
"selected": true
},
{
"id": "answer4",
"answer": "Personeller Aufwand",
"selected": false
}
]
}
},
{
"id": "question4",
"title": "Haben Sie Privatrezeptpatienten für inko?",
"order": 4,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Ja",
"selected": false
},
{
"id": "answer2",
"answer": "Nein",
"selected": true
}
]
}
},
{
"id": "question5",
"title": "Wie viele Patienten versorgen Sie regelmäßig?\n(Privat) un GKV",
"order": 5,
"type": "number",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "47",
"selected": true
}
]
}
},
{
"id": "question6",
"title": "Mit welchem Herstellern arbeiten Sie zusammen?",
"order": 6,
"type": "multiplChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "TZMO",
"selected": true
},
{
"id": "answer3",
"answer": "Essity",
"selected": true
},
{
"id": "answer4",
"answer": "Ontex",
"selected": false
},
{
"id": "answer5",
"answer": "Param",
"selected": false
},
{
"id": "answer6",
"answer": "Andere",
"selected": false
}
]
}
},
{
"id": "question7",
"title": "Was sind Ihre Gründe für die Zusammenarbeit?",
"order": 7,
"type": "matrix",
"data":
{
"questions":
[
{
"id": "subq1",
"title": "Preis",
"order": 1,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq2",
"title": "Einkaufskondition",
"order": 2,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
},
{
"id": "subq3",
"title": "Qualität",
"order": 3,
"answers":
[
{
"id": "answer1",
"answer": "HARTMANN",
"selected": false
},
{
"id": "answer2",
"answer": "Essity",
"selected": true
},
{
"id": "answer3",
"answer": "Ontex",
"selected": false
}
]
}
]
}
},
{
"id": "question8",
"title": "Beziehen Sie Produkte direkt oder über den Großhandel?",
"order": 8,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "primär direkt",
"selected": false
},
{
"id": "answer2",
"answer": "primär Großhandel",
"selected": true
},
{
"id": "answer3",
"answer": "ptimär teils. Großhandel",
"selected": true
},
{
"id": "answer4",
"answer": "unterschiedlich",
"selected": false
}
]
}
},
{
"id": "question9",
"title": "Gründe für Bezug?",
"order": 10,
"type": "singleChoice",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Umsatzziel mit Händler",
"selected": false
},
{
"id": "answer2",
"answer": "Warenverfügbarkeit/ Liefergeschwindigkeit und Frequent",
"selected": true
},
{
"id": "answer3",
"answer": "Einkaufskondition",
"selected": true
}
]
}
},
{
"id": "question10",
"title": "Weiter/Kommentare Hinweise?",
"order": 11,
"type": "freeText",
"data":
{
"answers":
[
{
"id": "answer1",
"answer": "Kommentar eintragen",
"selected": true
}
]
}
}
]