diff --git a/backend/configs/all4trees_config.json b/backend/configs/all4trees_config.json index 035acbab..5b6d7364 100644 --- a/backend/configs/all4trees_config.json +++ b/backend/configs/all4trees_config.json @@ -168,7 +168,7 @@ "revenue_change": "avg(0 - inc_dif if inc_evol = '2' else ifnull(inc_dif, 0))", "assets_idx_raw": "avg( ( ( int(hous_typ) + int(hous_size)) * int(hous_own) + ifnull(list_unique(ass),0) + int(lvst_yn) * ( int(lvst_s) + int(lvst_l) ) + ( int( land_own ) + int( land_titl ) + int( land_size ) if int(land_own) > 0 else 0) ) if ( ( int(hous_typ) + int(hous_size)) * int(hous_own) + list_unique(ass) + int(lvst_yn) * ( int(lvst_s) + int(lvst_l) ) + int( land_own ) + int( land_titl ) + int( land_size ) ) <= int(ass_tot_max) )", "assets_idx": "10 / int(ass_tot_max) * avg( ( ( int(hous_typ) + int(hous_size)) * int(hous_own) + ifnull(list_unique(ass),0) + int(lvst_yn) * ( int(lvst_s) + int(lvst_l) ) + ( int( land_own ) + int( land_titl ) + int( land_size ) if int(land_own) > 0 else 0) ) if ( ( int(hous_typ) + int(hous_size)) * int(hous_own) + list_unique(ass) + int(lvst_yn) * ( int(lvst_s) + int(lvst_l) ) + int( land_own ) + int( land_titl ) + int( land_size ) ) <= int(ass_tot_max) )", - "nb_income_sources" : "avg(int(inc_no))", + "nb_income_sources": "avg(int(inc_no))", "nb_additional_incomes": "'N/A'", "benef_pract1": "count(1 if int(pract1) = 2) / count(1) * 100", "benef_pract2": "count(1 if int(pract2) = 2) / count(1) * 100", diff --git a/backend/maps/datapackage_manager.py b/backend/maps/datapackage_manager.py index bcf24c3d..1cb3237a 100644 --- a/backend/maps/datapackage_manager.py +++ b/backend/maps/datapackage_manager.py @@ -96,7 +96,7 @@ def _handle_resources(self, loader_method: LoaderMethod): try: params = self.parse_params(loader_method) except ValueError as e: - return ParseError(e) + raise ParseError(e) # getting keys to get in self.request.FILES file_keys = ["data"] diff --git a/backend/maps/views.py b/backend/maps/views.py index d0a4bfe2..152bab8b 100644 --- a/backend/maps/views.py +++ b/backend/maps/views.py @@ -109,7 +109,7 @@ def remove_foreign_key_view(request): def get_map(user): user_map = copy(map) filter = '' - if user.is_authenticated: + if user.is_authenticated and bool(user.project): project = user.project if (project.lower() != ADMIN_PROJECT): filter = f"proj = '{project}' or conf = 1" diff --git a/webapp/src/app/routes/app-router-all4trees.tsx b/webapp/src/app/routes/app-router-all4trees.tsx index 27c454d7..73ef6785 100644 --- a/webapp/src/app/routes/app-router-all4trees.tsx +++ b/webapp/src/app/routes/app-router-all4trees.tsx @@ -2,12 +2,17 @@ import { lazy } from "react"; import { MapProviderAll4Trees } from "@app/providers/map-provider-all4trees"; +import { LAYERS } from "@shared/api/layers"; +import { useTranslation } from "@shared/i18n"; + import { AppRouterBase } from "./app-router-base"; const MainPage = lazy(() => import("@pages/all4trees/main")); const DashboardPage = lazy(() => import("@pages/all4trees/dashboard")); export const AppRouterAll4Trees = () => { + const { t } = useTranslation("all4trees"); + return ( { MapProvider={MapProviderAll4Trees} rootLayoutProps={{ hasDashboard: true, + layerOptions: [ + { + translation: t("filters.categories.actions.forestInventory"), + value: LAYERS.INVENTORY_FOR, + }, + { + translation: t("filters.categories.actions.treeDiversity"), + value: LAYERS.INVENTORY_BIO, + }, + { + translation: t("filters.categories.actions.socioEco"), + value: LAYERS.ENQUETE, + }, + ], logoSrc: "/logo_all4trees.png", }} /> diff --git a/webapp/src/app/styles/all4trees.css b/webapp/src/app/styles/all4trees.css index cf57a531..1f17d62e 100644 --- a/webapp/src/app/styles/all4trees.css +++ b/webapp/src/app/styles/all4trees.css @@ -91,6 +91,8 @@ --destructive: #dc2626; --info: var(--custom-color-blue-light); --info-foreground: var(--custom-color-blue-dark); + --success: rgb(237, 247, 237); + --success-foreground: #2e7d32; --border: rgba(0, 0, 0, 0.15); --input: #ffffff; @@ -135,6 +137,8 @@ --destructive: #ef4444; --info: var(--custom-color-blue-dark); --info-foreground: var(--custom-color-blue-light); + --success: rgb(12, 19, 13); + --success-foreground: #66bb6a; --border: rgba(255, 255, 255, 0.15); --input: rgba(255, 255, 255, 0.05); diff --git a/webapp/src/app/styles/index.css b/webapp/src/app/styles/index.css index 49f14edb..0f90e381 100644 --- a/webapp/src/app/styles/index.css +++ b/webapp/src/app/styles/index.css @@ -58,6 +58,8 @@ --color-destructive: var(--destructive); --color-info: var(--info); --color-info-foreground: var(--info-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); --color-border: var(--border); --color-input: var(--input); diff --git a/webapp/src/features/add-data/add-data-dialog.tsx b/webapp/src/features/add-data/add-data-dialog.tsx new file mode 100644 index 00000000..d58fe6fd --- /dev/null +++ b/webapp/src/features/add-data/add-data-dialog.tsx @@ -0,0 +1,205 @@ +import { AlertCircle, CheckCircle2, UploadIcon } from "lucide-react"; +import type { FC } from "react"; + +import { useTranslation } from "@i18n"; + +import { Alert, AlertAction, AlertDescription } from "@ui/alert"; +import { Button } from "@ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/dialog"; +import { Input } from "@ui/input"; +import { Label } from "@ui/label"; +import { RadioGroup, RadioGroupItem } from "@ui/radio-group"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ui/select"; + +import { + ACCEPTED_EXTENSIONS_BY_KIND, + RESOURCE_KINDS, + type ResourceKind, +} from "./constants"; +import { useAddData } from "./use-add-data"; + +// The value is the catalog folder name +export type LayerOptions = Array<{ value: string; translation: string }>; + +type AddDataDialogProps = { + layerOptions: LayerOptions; + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +export const AddDataDialog: FC = ({ + layerOptions, + onOpenChange, + open, +}) => { + const { t } = useTranslation("common"); + const { + error, + form, + isLoading, + isSuccess, + kind, + reset, + setFile, + setForm, + setKind, + submit, + } = useAddData(); + + const isFormData = kind === RESOURCE_KINDS.FormData; + + const handleOpenChange = (next: boolean) => { + if (!next) { + reset(); + } + onOpenChange(next); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + submit(); + }; + + return ( + + + + {t("addData.title")} + {t("addData.description")} + + +
+
+ + +
+ +
+ + setKind(value as ResourceKind)} + value={kind} + > +
+ + +
+
+ + +
+
+
+ +
+ + setFile(event.target.files?.[0] ?? null)} + type="file" + /> +

+ {isFormData + ? t("addData.hint.formData") + : t("addData.hint.externalData")} +

+
+ + {error && ( + + + + {t(`addData.error.${error}`)} + + + )} + + {isSuccess && ( + + + + {t("addData.success")} + + + + + + )} + + + + +
+
+
+ ); +}; diff --git a/webapp/src/features/add-data/constants.ts b/webapp/src/features/add-data/constants.ts new file mode 100644 index 00000000..b3a6c084 --- /dev/null +++ b/webapp/src/features/add-data/constants.ts @@ -0,0 +1,31 @@ +// Kind of resource the uploaded file represents. +export const RESOURCE_KINDS = { + ExternalData: "external_data", + FormData: "form_data", +} as const; + +export type ResourceKind = (typeof RESOURCE_KINDS)[keyof typeof RESOURCE_KINDS]; + +// coordo loader the backend selects, per resource kind. cf LoaderType +export const RESOURCE_TYPE_BY_KIND = { + [RESOURCE_KINDS.ExternalData]: "file", + [RESOURCE_KINDS.FormData]: "kobotoolbox", +} as const; + +// Accepted upload extensions per kind (coordo's file / kobotoolbox loaders). +// Form data is the multi-sheet KoboToolbox answers workbook (.xlsx). +export const ACCEPTED_EXTENSIONS_BY_KIND = { + [RESOURCE_KINDS.ExternalData]: [ + ".csv", + ".tsv", + ".tab", + ".xlsx", + ".xls", + ".xlsm", + ".xlsb", + ".odf", + ".ods", + ".odt", + ], + [RESOURCE_KINDS.FormData]: [".csv", ".xlsx"], +} as const; diff --git a/webapp/src/features/add-data/index.ts b/webapp/src/features/add-data/index.ts new file mode 100644 index 00000000..f341a4d1 --- /dev/null +++ b/webapp/src/features/add-data/index.ts @@ -0,0 +1 @@ +export { AddDataDialog, type LayerOptions } from "./add-data-dialog"; diff --git a/webapp/src/features/add-data/use-add-data.ts b/webapp/src/features/add-data/use-add-data.ts new file mode 100644 index 00000000..7f99b6d7 --- /dev/null +++ b/webapp/src/features/add-data/use-add-data.ts @@ -0,0 +1,76 @@ +import { useState } from "react"; + +import { useApi } from "@shared/hooks/useApi"; + +import { + RESOURCE_KINDS, + RESOURCE_TYPE_BY_KIND, + type ResourceKind, +} from "./constants"; + +type AddDataError = "generic" | "missingFile" | "missingForm"; + +export const useAddData = () => { + const api = useApi(); + + const [form, setForm] = useState(""); + const [file, setFile] = useState(null); + const [kind, setKind] = useState(RESOURCE_KINDS.FormData); + const [isLoading, setIsLoading] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setError(null); + setFile(null); + setForm(""); + setIsSuccess(false); + setKind(RESOURCE_KINDS.FormData); + }; + + const submit = async () => { + setError(null); + setIsSuccess(false); + + if (!form) { + setError("missingForm"); + return; + } + if (!file) { + setError("missingFile"); + return; + } + + setIsLoading(true); + + const formData = new FormData(); + formData.append("resource_type", RESOURCE_TYPE_BY_KIND[kind]); + formData.append("package", `catalog/${form}`); + formData.append("data", file); + + try { + await api.appendData(formData); + setFile(null); + setForm(""); + setIsSuccess(true); + } catch { + setError("generic"); + } finally { + setIsLoading(false); + } + }; + + return { + error, + file, + form, + isLoading, + isSuccess, + kind, + reset, + setFile, + setForm, + setKind, + submit, + }; +}; diff --git a/webapp/src/shared/api/client.ts b/webapp/src/shared/api/client.ts index 3f2f4ada..e496dd04 100644 --- a/webapp/src/shared/api/client.ts +++ b/webapp/src/shared/api/client.ts @@ -43,6 +43,12 @@ export const fetchJSONWithAuth = async ( ) => (await fetchWithAuth(endpoint, options, authToken)).json(); export const createApiClient = (authToken: string | null) => ({ + appendData: (formData: FormData) => + fetchWithAuth( + `/maps/append-data/`, + { body: formData, method: "POST" }, + authToken, + ), getCatalogResource: (layerId: string, resourceName: string) => fetchJSONWithAuth(`/catalog/${layerId}/${resourceName}`, {}, authToken), getCatalogResourceList: (layerId: string, resourceList: string[]) => diff --git a/webapp/src/shared/i18n/translations/en/common.json b/webapp/src/shared/i18n/translations/en/common.json index b519af44..f9d753ad 100644 --- a/webapp/src/shared/i18n/translations/en/common.json +++ b/webapp/src/shared/i18n/translations/en/common.json @@ -1,4 +1,33 @@ { + "addData": { + "button": { + "pending": "Uploading...", + "submit": "Add data" + }, + "description": "Upload a file to add data to the map.", + "error": { + "generic": "Upload failed. Please try again.", + "missingFile": "Please choose a file to upload.", + "missingForm": "Please select a data source to update." + }, + "field": { + "file": "File to upload", + "form": "Data source to update", + "formPlaceholder": "Select a data source", + "kind": "Kind of resource" + }, + "hint": { + "externalData": "The file name must match the target resource (e.g. \"for_dw.csv\"); Excel sheets are matched by sheet name.", + "formData": "Upload the KoboToolbox answers workbook (e.g. \"…DonneesK.xlsx\"). Rows are appended to the data source." + }, + "kind": { + "externalData": "External data", + "formData": "Form data" + }, + "refreshData": "Reload", + "success": "Data added successfully.", + "title": "Add data" + }, "auth": { "loginForm": { "button": { @@ -36,6 +65,7 @@ "system": "System" }, "menu": { + "addData": "Add data", "goToAdmin": "Admin", "language": "Language", "login": "Login", diff --git a/webapp/src/shared/i18n/translations/fr/common.json b/webapp/src/shared/i18n/translations/fr/common.json index facf510a..ba82f8e2 100644 --- a/webapp/src/shared/i18n/translations/fr/common.json +++ b/webapp/src/shared/i18n/translations/fr/common.json @@ -1,4 +1,33 @@ { + "addData": { + "button": { + "pending": "Envoi en cours...", + "submit": "Ajouter les données" + }, + "description": "Importez un fichier pour ajouter des données à la carte.", + "error": { + "generic": "Échec de l'import. Veuillez réessayer.", + "missingFile": "Veuillez choisir un fichier à importer.", + "missingForm": "Veuillez sélectionner une source de données à mettre à jour." + }, + "field": { + "file": "Fichier à importer", + "form": "Source de données à mettre à jour", + "formPlaceholder": "Sélectionnez une source de données", + "kind": "Type de ressource" + }, + "hint": { + "externalData": "Le nom du fichier doit correspondre à la ressource cible (ex. « for_dw.csv ») ; les feuilles Excel sont associées par nom de feuille.", + "formData": "Importez le classeur de réponses KoboToolbox (ex. « …DonneesK.xlsx »). Les lignes sont ajoutées à la source de données." + }, + "kind": { + "externalData": "Données externes", + "formData": "Données de formulaire" + }, + "refreshData": "Rafraichir", + "success": "Données ajoutées avec succès.", + "title": "Ajouter des données" + }, "auth": { "loginForm": { "button": { @@ -36,6 +65,7 @@ "system": "Système" }, "menu": { + "addData": "Ajouter des données", "goToAdmin": "Espace Admin", "language": "Langue", "login": "Connexion", diff --git a/webapp/src/shared/ui/alert.tsx b/webapp/src/shared/ui/alert.tsx index f5550958..48decbc0 100644 --- a/webapp/src/shared/ui/alert.tsx +++ b/webapp/src/shared/ui/alert.tsx @@ -18,6 +18,8 @@ const alertVariants = cva( destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", info: "bg-info text-info-foreground [&>svg]:text-info-foreground", + success: + "bg-success text-success-foreground [&>svg]:text-success-foreground", }, }, }, diff --git a/webapp/src/shared/ui/dialog.tsx b/webapp/src/shared/ui/dialog.tsx new file mode 100644 index 00000000..0027b723 --- /dev/null +++ b/webapp/src/shared/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/webapp/src/widgets/header/index.tsx b/webapp/src/widgets/header/index.tsx index c8d1aeb7..d73451f3 100644 --- a/webapp/src/widgets/header/index.tsx +++ b/webapp/src/widgets/header/index.tsx @@ -5,14 +5,18 @@ import { useTranslation } from "@shared/i18n"; import { Button } from "@shared/ui/button"; import { URLS, useAbsoluteUrls } from "@shared/urls"; -import { UserMenu } from "./user-menu"; +import { UserMenu, type UserMenuProps } from "./user-menu"; export type HeaderProps = { logoSrc: string; hasDashboard?: boolean; -}; +} & UserMenuProps; -export const Header: FC = ({ logoSrc, hasDashboard }) => { +export const Header: FC = ({ + logoSrc, + hasDashboard, + layerOptions, +}) => { const navigate = useNavigate(); const location = useLocation(); const isDashboardPage = @@ -47,7 +51,7 @@ export const Header: FC = ({ logoSrc, hasDashboard }) => { : t("header.navigationButton.toDashboard")} )} - +
diff --git a/webapp/src/widgets/header/user-menu.tsx b/webapp/src/widgets/header/user-menu.tsx index 66f04deb..a4ce6142 100644 --- a/webapp/src/widgets/header/user-menu.tsx +++ b/webapp/src/widgets/header/user-menu.tsx @@ -1,7 +1,15 @@ -import { DatabaseIcon, LogOutIcon, SettingsIcon, UserIcon } from "lucide-react"; +import { + DatabaseIcon, + LogOutIcon, + MapPinPlusIcon, + SettingsIcon, + UserIcon, +} from "lucide-react"; import type { FC } from "react"; +import { useState } from "react"; import { useNavigate } from "react-router-dom"; +import { AddDataDialog, type LayerOptions } from "@features/add-data"; import { useCreateAdminSession } from "@features/admin/use-create-admin-session"; import { useAuth } from "@features/auth"; @@ -20,80 +28,104 @@ import { import { LanguageSelector } from "./language-selector"; import { ModeToggle } from "./mode-toggle"; -export const UserMenu: FC = () => { +export type UserMenuProps = { + layerOptions?: LayerOptions; +}; + +export const UserMenu: FC = ({ layerOptions }) => { const { t } = useTranslation("common"); const navigate = useNavigate(); const { isAuthenticated, logout, token } = useAuth(); const { isLoading, createAdminSession } = useCreateAdminSession(token); + const [isAddDataOpen, setIsAddDataOpen] = useState(false); + const absoluteUrls = useAbsoluteUrls(); const onLogin = () => navigate(absoluteUrls.LOGIN); return ( - - - + + + - - User menu - - - - - - - - - - - {isAuthenticated ? ( - <> + + + + + + + {isAuthenticated ? ( + <> + setIsAddDataOpen(true)} + > + + {t("header.menu.addData")} + + + + + {t("header.menu.goToAdmin")} + + + alert("Not implemented yet")} + > + + {t("header.menu.seeDatabase")} + + + + + + + {t("header.menu.logout")} + + + ) : ( - - {t("header.menu.goToAdmin")} + + {t("header.menu.login")} - - alert("Not implemented yet")} - > - - {t("header.menu.seeDatabase")} - - - - - - - {t("header.menu.logout")} - - - ) : ( - - - {t("header.menu.login")} - - )} - - + )} + + + + {isAuthenticated && layerOptions?.length && ( + + )} + ); };