diff --git a/web/public/images/warehouse-pin.svg b/web/public/images/warehouse-pin.svg
new file mode 100644
index 00000000..773745b2
--- /dev/null
+++ b/web/public/images/warehouse-pin.svg
@@ -0,0 +1,17 @@
+
diff --git a/web/src/App.svelte b/web/src/App.svelte
index 0e9f85cf..0b403591 100644
--- a/web/src/App.svelte
+++ b/web/src/App.svelte
@@ -5,7 +5,6 @@
import Dashboard from "./routes/backOffice/dashboard/Dashboard.svelte";
import Map from "./routes/backOffice/map/Map.svelte";
import Routes from "./routes/backOffice/routes/Routes.svelte";
- import Warehouses from "./routes/backOffice/warehouses/Warehouses.svelte";
import Trucks from "./routes/backOffice/trucks/Trucks.svelte";
import Reports from "./routes/backOffice/reports/Reports.svelte";
import Employees from "./routes/backOffice/employees/Employees.svelte";
@@ -20,6 +19,7 @@
import url from "./lib/stores/url";
import ContainersRouter from "./routes/backOffice/containers/ContainersRouter.svelte";
import Toast from "./lib/components/Toast.svelte";
+ import WarehousesRouter from "./routes/backOffice/warehouses/WarehousesRouter.svelte";
onMount(() => {
if ($url.pathname === "/") {
@@ -36,10 +36,9 @@
-
+
+
+
diff --git a/web/src/domain/warehouse.ts b/web/src/domain/warehouse.ts
new file mode 100644
index 00000000..9e2f14a1
--- /dev/null
+++ b/web/src/domain/warehouse.ts
@@ -0,0 +1,19 @@
+import type { components } from "../../api/ecomap/http";
+
+/**
+ * Warehouse.
+ */
+export type Warehouse = components["schemas"]["Warehouse"];
+
+/**
+ * Paginated warehouses.
+ */
+export type PaginatedWarehouses = components["schemas"]["WarehousesPaginated"];
+
+/**
+ * Filters of warehouses.
+ */
+export interface WarehousesFilters {
+ pageIndex: number;
+ location: string;
+}
diff --git a/web/src/lib/components/Field.svelte b/web/src/lib/components/Field.svelte
index 63a7b60b..3488ce31 100644
--- a/web/src/lib/components/Field.svelte
+++ b/web/src/lib/components/Field.svelte
@@ -10,7 +10,7 @@
* The value of the field.
* @default null
*/
- export let value: string | null = null;
+ export let value: number | string | null = null;
diff --git a/web/src/lib/components/Input.svelte b/web/src/lib/components/Input.svelte
index 6c4efa7c..fb3c28d2 100644
--- a/web/src/lib/components/Input.svelte
+++ b/web/src/lib/components/Input.svelte
@@ -2,6 +2,7 @@
import type {
ChangeEventHandler,
HTMLInputAttributes,
+ KeyboardEventHandler,
MouseEventHandler,
} from "svelte/elements";
import Icon from "./Icon.svelte";
@@ -30,6 +31,18 @@
*/
export let id: HTMLInputAttributes["id"] = null;
+ /**
+ * Defines the maximum value that is acceptable and valid for the input.
+ * @default null
+ */
+ export let max: HTMLInputAttributes["max"] = null;
+
+ /**
+ * Defines the minimum value that is acceptable and valid for the input.
+ * @default null
+ */
+ export let min: HTMLInputAttributes["min"] = null;
+
/**
* The name of the form control. Submitted with the form as part of a name/value pair.
* @default null
@@ -48,6 +61,12 @@
*/
export let onInput: ChangeEventHandler | null = null;
+ /**
+ * Callback fired when a key is pressed.
+ * @default null
+ */
+ export let onKeyDown: KeyboardEventHandler | null = null;
+
/**
* The text that appears in the form control when it has no value set.
* @default null
@@ -60,6 +79,18 @@
*/
export let readonly: boolean = false;
+ /**
+ * Indicates that the user must specify a value for the input before the owning form can be submitted.
+ * @default false
+ */
+ export let required: boolean = false;
+
+ /**
+ * CSS styling declarations to be applied to the element.
+ * @default null
+ */
+ export let style: string | null = null;
+
/**
* The type of control to render.
* @default null
@@ -70,7 +101,7 @@
* The value of the input.
* @default null
*/
- export let value: string | null = null;
+ export let value: number | string | null = null;
@@ -83,6 +114,11 @@
{type}
{value}
{readonly}
+ {required}
+ {max}
+ {min}
+ {style}
+ on:keydown={onKeyDown}
on:input={onInput}
on:click={onClick}
data-endIcon={endIcon}
diff --git a/web/src/lib/components/LocationInput.svelte b/web/src/lib/components/LocationInput.svelte
new file mode 100644
index 00000000..467c16d0
--- /dev/null
+++ b/web/src/lib/components/LocationInput.svelte
@@ -0,0 +1,72 @@
+
+
+
diff --git a/web/src/lib/components/Select.svelte b/web/src/lib/components/Select.svelte
index b34bb8a2..56f2a223 100644
--- a/web/src/lib/components/Select.svelte
+++ b/web/src/lib/components/Select.svelte
@@ -38,6 +38,12 @@
*/
export let placeholder: string | null = null;
+ /**
+ * Indicates that the user must specify a value for the input before the owning form can be submitted.
+ * @default false
+ */
+ export let required: boolean = false;
+
/**
* The value of the select.
* @default ""
@@ -50,6 +56,7 @@
{name}
{value}
{placeholder}
+ {required}
on:change={onChange}
class={`${className} ${error ? "error" : ""}`}
>
diff --git a/web/src/lib/components/SelectLocation.svelte b/web/src/lib/components/SelectLocation.svelte
index cb3b79d2..39ae1d4b 100644
--- a/web/src/lib/components/SelectLocation.svelte
+++ b/web/src/lib/components/SelectLocation.svelte
@@ -19,6 +19,7 @@
import {
DEFAULT_ANIMATION_DURATION,
DEFAULT_MAX_ZOOM,
+ DEFAULT_PIN_ICON_SRC,
} from "../constants/map";
/**
@@ -51,6 +52,12 @@
*/
export let onCancel: (() => void) | null = null;
+ /**
+ * Map layer style.
+ * @default "/images/pin.svg"
+ */
+ export let iconSrc: string = DEFAULT_PIN_ICON_SRC;
+
/**
* Open Layers map.
*/
@@ -67,7 +74,7 @@
const layer = new VectorLayer({
source: new VectorSource>({ features: [] }),
style: {
- "icon-src": "/images/pin.svg",
+ "icon-src": iconSrc,
},
});
diff --git a/web/src/lib/components/map/mapUtils.ts b/web/src/lib/components/map/mapUtils.ts
index 324c79c5..d06e4e5d 100644
--- a/web/src/lib/components/map/mapUtils.ts
+++ b/web/src/lib/components/map/mapUtils.ts
@@ -24,6 +24,7 @@ import {
colorLayerKey,
nameLayerKey,
DEFAULT_MAX_ZOOM,
+ CONTAINER_ICON_SRC,
} from "../../constants/map";
import type { Geometry } from "ol/geom";
import type Feature from "ol/Feature";
@@ -53,7 +54,7 @@ const defaultVectorStyle: VectorStyle = {
* Default style for WebGl point layer.
*/
const defaultIconStyle: WebGLStyle = {
- "icon-src": "/images/logo.svg",
+ "icon-src": CONTAINER_ICON_SRC,
};
/**
@@ -61,7 +62,7 @@ const defaultIconStyle: WebGLStyle = {
*/
const defaultClusterIcon = new Style({
image: new Icon({
- src: "/images/logo.svg",
+ src: CONTAINER_ICON_SRC,
}),
});
diff --git a/web/src/lib/constants/map.ts b/web/src/lib/constants/map.ts
index 4652b21d..a8752a84 100644
--- a/web/src/lib/constants/map.ts
+++ b/web/src/lib/constants/map.ts
@@ -32,3 +32,18 @@ export const OL_PROJECTION = "EPSG:3857";
* Resource geometry projection.
*/
export const RESOURCE_PROJECTION = "EPSG:4326";
+
+/**
+ * Source location of the default pin icon.
+ */
+export const DEFAULT_PIN_ICON_SRC = "/images/pin.svg";
+
+/**
+ * Source location of the container pin icon.
+ */
+export const CONTAINER_ICON_SRC = "/images/logo.svg";
+
+/**
+ * Source location of the warehouse pin icon.
+ */
+export const WAREHOUSE_ICON_SRC = "/images/warehouse-pin.svg";
diff --git a/web/src/lib/utils/i8n.ts b/web/src/lib/utils/i8n.ts
index 6ae9b0ee..5013dc16 100644
--- a/web/src/lib/utils/i8n.ts
+++ b/web/src/lib/utils/i8n.ts
@@ -73,6 +73,9 @@ function _locale(): Writable {
localStorage.setItem("locale", supportedLocale);
+ // Set the supported locale in the lang attribute of the HTML element.
+ document.documentElement.setAttribute("lang", supportedLocale);
+
const store = writable(supportedLocale);
const { subscribe, set } = store;
@@ -82,6 +85,9 @@ function _locale(): Writable {
const supportedLocale = getSupportedLocale(locale);
localStorage.setItem("locale", supportedLocale);
+ // Set the supported locale in the lang attribute of the HTML element.
+ document.documentElement.setAttribute("lang", supportedLocale);
+
// Refresh current page when switching locales.
location.reload();
@@ -93,6 +99,9 @@ function _locale(): Writable {
const supportedLocale = getSupportedLocale(updatedLocale);
localStorage.setItem("locale", supportedLocale);
+ // Set the supported locale in the lang attribute of the HTML element.
+ document.documentElement.setAttribute("lang", supportedLocale);
+
// Refresh current page when switching locales.
location.reload();
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index c950bc8e..02e54a85 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -11,16 +11,17 @@
"save": "Save",
"preview": "Preview",
"selectLocation": "Select location",
+ "location": "Location",
+ "location.placeholder": "Select a location",
+ "location.search": "Search by location",
"location.unknownWay": "Unknown way",
"containers.title": "Containers",
- "containers.search": "Search by location",
"containers.notFound.title": "Container not found",
"containers.notFound.description": "The container you are looking for does not exist.",
"containers.create.title": "Add container",
"containers.create.success": "Container added successfully",
"containers.category": "Category",
- "containers.location": "Location",
- "containers.location.placeholder": "Select a location",
+ "truckCapacity.placeholder": "Enter the maximum truck capacity",
"containers.category.general": "General",
"containers.category.glass": "Glass",
"containers.category.hazardous": "Hazardous",
@@ -33,7 +34,26 @@
"containers.delete.success": "Container deleted successfully",
"containers.delete.conflict.title": "Conflict",
"containers.delete.conflict.description": "This container cannot be deleted because it's associated with a report, bookmark, or route.",
- "error.requiredField": "Required field",
+ "warehouses": "Warehouses",
+ "truckCapacity": "Maximum truck capacity",
+ "warehouses.create.title": "Add warehouse",
+ "warehouses.create.success": "Warehouse added successfully",
+ "warehouses.update.success": "Warehouse updated successfully",
+ "warehouses.update.conflict.title": "Conflict",
+ "warehouses.update.conflict.description": "The warehouse already has more trucks than the new capacity.",
+ "warehouses.notFound.title": "Warehouse not found",
+ "warehouses.notFound.description": "The warehouse you are looking for does not exist.",
+ "warehouses.delete.success": "Warehouse deleted successfully",
+ "warehouses.delete.conflict.title": "Conflict",
+ "warehouses.delete.conflict.description": "This warehouse cannot be deleted because it's associated with a truck or a route.",
+ "error.valueMissing": "Required field",
+ "error.typeMismatch.number": "Invalid number",
+ "error.patternMismatch": "Invalid format",
+ "error.tooLong": "Value must be {{maxLength}} characters or less",
+ "error.tooShort": "Value must be {{minLength}} characters or more",
+ "error.rangeUnderflow": "Value must not be less than {{min}}",
+ "error.rangeOverflow": "Value must not be greater than {{max}}",
+ "error.stepMismatch": "Invalid value",
"error.unexpected": "An unexpected error occurred. Please try again later or contact the support team.",
"error.unexpected.title": "Oops! An unexpected error occurred",
"error.unexpected.description": "Please try again later or contact the support team.",
diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json
index 0a652407..2f32302a 100644
--- a/web/src/locales/pt.json
+++ b/web/src/locales/pt.json
@@ -11,16 +11,17 @@
"save": "Guardar",
"preview": "Pré-visualização",
"selectLocation": "Selecionar localização",
+ "location": "Localização",
+ "location.placeholder": "Selecione uma localização",
+ "location.search": "Pesquise por localização",
"location.unknownWay": "Rua desconhecida",
"containers.title": "Contentores",
"containers.notFound.title": "Contentor não encontrado",
"containers.notFound.description": "O contentor que procura não existe.",
"containers.create.title": "Adicionar contentor",
"containers.create.success": "Contentor adicionado com sucesso",
- "containers.search": "Pesquise por localização",
"containers.category": "Categoria",
- "containers.location": "Localização",
- "containers.location.placeholder": "Selecione uma localização",
+ "truckCapacity.placeholder": "Introduza a capacidade máxima de camiões",
"containers.category.general": "Geral",
"containers.category.glass": "Vidro",
"containers.category.hazardous": "Perigoso",
@@ -33,7 +34,26 @@
"containers.delete.success": "Contentor eliminado com sucesso",
"containers.delete.conflict.title": "Conflito",
"containers.delete.conflict.description": "Este contentor não pode ser eliminado porque está associado a um reporte, marcador ou rota.",
- "error.requiredField": "Campo obrigatório",
+ "warehouses": "Armazéns",
+ "truckCapacity": "Capacidade máxima de camiões",
+ "warehouses.create.title": "Adicionar armazém",
+ "warehouses.create.success": "Armazém adicionado com sucesso",
+ "warehouses.update.success": "Armazém atualizado com sucesso",
+ "warehouses.update.conflict.title": "Conflicto",
+ "warehouses.update.conflict.description": "O armazém já tem mais camiões do que a nova capacidade.",
+ "warehouses.notFound.title": "Armazém não encontrado",
+ "warehouses.notFound.description": "O armazém que procura não existe.",
+ "warehouses.delete.success": "Armazém eliminado com sucesso",
+ "warehouses.delete.conflict.title": "Conflito",
+ "warehouses.delete.conflict.description": "Este armazém não pode ser eliminado porque está associado a um camião ou a uma rota.",
+ "error.valueMissing": "Campo obrigatório",
+ "error.typeMismatch.number": "Número inválido",
+ "error.patternMismatch": "Formato inválido",
+ "error.tooLong": "Valor tem de ter {{maxLength}} caracteres ou menos",
+ "error.tooShort": "Valor tem de ter {{minLength}} caracteres ou mais",
+ "error.rangeUnderflow": "Valor não deve ser inferior a {{min}}",
+ "error.rangeOverflow": "Valor não deve ser superior a {{max}}",
+ "error.stepMismatch": "Valor inválido",
"error.unexpected": "Ocorreu um erro inesperado. Tente novamente mais tarde ou contacte a equipa de apoio.",
"error.unexpected.title": "Oops! Ocorreu um erro inesperado",
"error.unexpected.description": "Tente novamente mais tarde ou contacte a equipa de apoio.",
diff --git a/web/src/locales/schema.json b/web/src/locales/schema.json
index 4b2e75b1..c007194f 100644
--- a/web/src/locales/schema.json
+++ b/web/src/locales/schema.json
@@ -34,6 +34,15 @@
"selectLocation": {
"type": "string"
},
+ "location": {
+ "type": "string"
+ },
+ "location.placeholder": {
+ "type": "string"
+ },
+ "location.search": {
+ "type": "string"
+ },
"location.unknownWay": {
"type": "string"
},
@@ -52,16 +61,10 @@
"containers.create.success": {
"type": "string"
},
- "containers.search": {
- "type": "string"
- },
"containers.category": {
"type": "string"
},
- "containers.location": {
- "type": "string"
- },
- "containers.location.placeholder": {
+ "truckCapacity.placeholder": {
"type": "string"
},
"containers.category.general": {
@@ -100,7 +103,64 @@
"containers.delete.conflict.description": {
"type": "string"
},
- "error.requiredField": {
+ "warehouses": {
+ "type": "string"
+ },
+ "truckCapacity": {
+ "type": "string"
+ },
+ "warehouses.create.title": {
+ "type": "string"
+ },
+ "warehouses.create.success": {
+ "type": "string"
+ },
+ "warehouses.update.success": {
+ "type": "string"
+ },
+ "warehouses.update.conflict.title": {
+ "type": "string"
+ },
+ "warehouses.update.conflict.description": {
+ "type": "string"
+ },
+ "warehouses.notFound.title": {
+ "type": "string"
+ },
+ "warehouses.notFound.description": {
+ "type": "string"
+ },
+ "warehouses.delete.success": {
+ "type": "string"
+ },
+ "warehouses.delete.conflict.title": {
+ "type": "string"
+ },
+ "warehouses.delete.conflict.description": {
+ "type": "string"
+ },
+ "error.valueMissing": {
+ "type": "string"
+ },
+ "error.typeMismatch.number": {
+ "type": "string"
+ },
+ "error.patternMismatch": {
+ "type": "string"
+ },
+ "error.tooLong": {
+ "type": "string"
+ },
+ "error.tooShort": {
+ "type": "string"
+ },
+ "error.rangeUnderflow": {
+ "type": "string"
+ },
+ "error.rangeOverflow": {
+ "type": "string"
+ },
+ "error.stepMismatch": {
"type": "string"
},
"error.unexpected": {
diff --git a/web/src/routes/backOffice/containers/components/ContainerForm.svelte b/web/src/routes/backOffice/containers/components/ContainerForm.svelte
index dd43b15c..a938cc5c 100644
--- a/web/src/routes/backOffice/containers/components/ContainerForm.svelte
+++ b/web/src/routes/backOffice/containers/components/ContainerForm.svelte
@@ -8,7 +8,6 @@
import DetailsFields from "../../../../lib/components/details/DetailsFields.svelte";
import DetailsSection from "../../../../lib/components/details/DetailsSection.svelte";
import DetailsContent from "../../../../lib/components/details/DetailsContent.svelte";
- import Input from "../../../../lib/components/Input.svelte";
import Map from "../../../../lib/components/map/Map.svelte";
import OlMap from "ol/Map";
import Select from "../../../../lib/components/Select.svelte";
@@ -30,6 +29,8 @@
} from "../../../../lib/utils/map";
import { isValidContainerCategory } from "../utils/category";
import { getLocationName } from "../../../../lib/utils/location";
+ import { CONTAINER_ICON_SRC } from "../../../../lib/constants/map";
+ import LocationInput from "../../../../lib/components/LocationInput.svelte";
/**
* The back route.
@@ -66,7 +67,7 @@
const layer = new VectorLayer({
source: new VectorSource>({ features: [] }),
style: {
- "icon-src": "/images/logo.svg",
+ "icon-src": CONTAINER_ICON_SRC,
},
});
@@ -130,13 +131,13 @@
*/
function validateForm(category: string, location: string) {
if (!category) {
- formErrorMessages.category = $t("error.requiredField");
+ formErrorMessages.category = $t("error.valueMissing");
} else {
formErrorMessages.category = "";
}
if (!location) {
- formErrorMessages.location = $t("error.requiredField");
+ formErrorMessages.location = $t("error.valueMissing");
} else {
formErrorMessages.location = "";
}
@@ -178,7 +179,7 @@
}
-