diff --git a/fdm-app/.eslintrc.cjs b/fdm-app/.eslintrc.cjs new file mode 100644 index 000000000..eeefc6ec5 --- /dev/null +++ b/fdm-app/.eslintrc.cjs @@ -0,0 +1,90 @@ +/** + * This is intended to be a basic starting point for linting in your app. + * It relies on recommended configs out of the box for simplicity, but you can + * and should modify this configuration to best suit your team's needs. + */ + +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + env: { + browser: true, + commonjs: true, + es6: true, + }, + ignorePatterns: ["!**/.server", "!**/.client"], + + // Base config + extends: ["eslint:recommended"], + + overrides: [ + // React + { + files: ["**/*.{js,jsx,ts,tsx}"], + plugins: ["react", "jsx-a11y"], + extends: [ + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + ], + settings: { + react: { + version: "detect", + }, + formComponents: ["Form"], + linkComponents: [ + { name: "Link", linkAttribute: "to" }, + { name: "NavLink", linkAttribute: "to" }, + ], + "import/resolver": { + typescript: {}, + alias: { + map:[ + ["@/app", "~/"] + + ] + } + }, + }, + }, + + // Typescript + { + files: ["**/*.{ts,tsx}"], + plugins: ["@typescript-eslint", "import"], + parser: "@typescript-eslint/parser", + settings: { + "import/internal-regex": "^~/", + "import/resolver": { + node: { + extensions: [".ts", ".tsx"], + }, + typescript: { + alwaysTryTypes: true, + }, + }, + }, + extends: [ + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + ], + }, + + // Node + { + files: [".eslintrc.cjs"], + env: { + node: true, + }, + }, + ], +}; diff --git a/fdm-app/.gitignore b/fdm-app/.gitignore new file mode 100644 index 000000000..80ec311f4 --- /dev/null +++ b/fdm-app/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env diff --git a/fdm-app/CHANGELOG.md b/fdm-app/CHANGELOG.md new file mode 100644 index 000000000..dfc8b4ab7 --- /dev/null +++ b/fdm-app/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog fdm-app + +## v.0.1.0 + +A first prototype of an application for fdm with minimal functions \ No newline at end of file diff --git a/fdm-app/README.md b/fdm-app/README.md new file mode 100644 index 000000000..49312527f --- /dev/null +++ b/fdm-app/README.md @@ -0,0 +1,3 @@ +# fdm-app + +This contains the application for the fdm-app \ No newline at end of file diff --git a/fdm-app/app/components/app-sidebar.tsx b/fdm-app/app/components/app-sidebar.tsx new file mode 100644 index 000000000..041e5d00a --- /dev/null +++ b/fdm-app/app/components/app-sidebar.tsx @@ -0,0 +1,251 @@ +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" +import { Badge } from "@/components/ui/badge" +import { ArrowRightLeft, BadgeCheck, ChevronsUpDown, GitPullRequestArrow, House, Languages, LifeBuoy, LogOut, Map as MapIcon, PawPrint, Scale, Send, Settings, Shapes, Sparkles, Sprout, Square } from "lucide-react" +import { Avatar, AvatarFallback } from "./ui/avatar" +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu" + +import { useIsMobile } from '@/hooks/use-mobile' +import { Button } from "./ui/button" +import { Form } from "@remix-run/react" + +interface SideBarType { + user: { + firstname: string + surname: string + email: string + avatar: string | null + } +} + + +export function AppSidebar(props: SideBarType) { + + const user = props.user + const avatarInitials = props.user.firstname.slice(0, 1).toUpperCase() + props.user.surname.slice(0, 1).toUpperCase() + const isMobile = useIsMobile() + + return ( + + + + + + +
+ +
+
+ FDM + {/* 2024 */} +
+
+
+
+
+
+ + + Mijn bedrijf + + + + + + + Bedrijf + + + + + + + + Kaart + + + + + + + + Percelen + + + + + + + + Gewassen + + + + + + + + Meststoffen + + + + + + + + Stal & dieren + + + + + + + + Apps + + + + + + + MINAS2 + + + + Binnenkort + + + + + + + OS Balans + + + + Binnenkort + + + + + + + BAAT + + + + Binnenkort + + + + + + + + + + + + + Ondersteuning + + + + + + + + Feedback + + + + + + + + + + + + + + + {/* */} + {avatarInitials} + +
+ {user.firstname + " " + user.surname} + {user.email} +
+ +
+
+ + +
+ + {/* */} + {avatarInitials} + +
+ {user.firstname + " " + user.surname} + {user.email} +
+
+
+ + + + + Wat is er nieuw? + + + + + + + Account + + + + Taal + + + + Instellingen + + + + + +
+ +
+
+
+
+
+
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/farm.tsx b/fdm-app/app/components/blocks/farm.tsx new file mode 100644 index 000000000..db03ab5c3 --- /dev/null +++ b/fdm-app/app/components/blocks/farm.tsx @@ -0,0 +1,100 @@ +import { useState } from "react"; +import { useNavigation, Form } from "@remix-run/react"; + +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +import { MultiSelect } from "@/components/custom/multi-select" + + +export interface fertilizersListType { + value: string + label: string +} + +export interface farmType { + b_name_farm: string | null + b_fertilizers_organic: string[] + b_fertilizers_mineral: string[] + organicFertilizersList: fertilizersListType[] + mineralFertilizersList: fertilizersListType[] + action: "/app/addfarm/new" +} + +export function Farm(props: farmType) { + const organicFertilizersList = props.organicFertilizersList + const mineralFertilizersList = props.mineralFertilizersList + const [selectedOrganicFertilizers, setOrganicFertilizers] = useState(props.b_fertilizers_organic); + const [selectedMineralFertilizers, setMineralFertilizers] = useState(props.b_fertilizers_mineral); + + const navigation = useNavigation(); + + return ( +
+ +
+
+ + Bedrijf + Wat voor soort bedrijf heb je? + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+
+ + ) +} diff --git a/fdm-app/app/components/blocks/field-map.tsx b/fdm-app/app/components/blocks/field-map.tsx new file mode 100644 index 000000000..3f89ebfe9 --- /dev/null +++ b/fdm-app/app/components/blocks/field-map.tsx @@ -0,0 +1,72 @@ +import { useMemo } from 'react'; +import { Map as MapGL, Source, Layer } from 'react-map-gl' +import type { FeatureCollection } from "geojson"; +import 'mapbox-gl/dist/mapbox-gl.css'; +import geojsonExtent from '@mapbox/geojson-extent' + +interface FieldMapType { + b_geojson: FeatureCollection + mapboxToken: string +} + +const brpFieldsFillStyle = { + id: 'brp-fields-fill', + type: 'fill', + paint: { + 'fill-color': "#93c5fd", + 'fill-opacity': 0.5, + 'fill-outline-color': "#1e3a8a" + } +}; +const brpFieldsLineStyle = { + id: 'brp-fields-line', + type: 'line', + paint: { + 'line-color': "#1e3a8a", + 'line-opacity': 0.8, + 'line-width': 2, + } +}; + + +export function FieldMap(props: FieldMapType) { + const mapboxToken = props.mapboxToken + + // Convert geometry to geoJSON + const bounds = useMemo(() => { + try { + return geojsonExtent(props.b_geojson); + } catch (error) { + console.error('Failed to calculate bounds:', error); + return [-180, -90, 180, 90]; // Default world bounds + } + }, [props.b_geojson]); + + return ( + console.error('Map error:', e)} + > + + console.error('Source loading error:', e)}> + + + + + + ) +} + diff --git a/fdm-app/app/components/blocks/fields-map.tsx b/fdm-app/app/components/blocks/fields-map.tsx new file mode 100644 index 000000000..428be6f75 --- /dev/null +++ b/fdm-app/app/components/blocks/fields-map.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { useFetcher, useNavigation } from "@remix-run/react"; +import { Map, GeolocateControl, NavigationControl, Source, Layer } from 'react-map-gl' +import 'mapbox-gl/dist/mapbox-gl.css'; +import { Button } from "../ui/button"; + +interface FieldsMapType { + mapboxToken: string +} + +const brpFieldsFillStyle = { + id: 'brp-fields-fill', + type: 'fill', + paint: { + 'fill-color': "#93c5fd", + 'fill-opacity': 0.5, + 'fill-outline-color': "#1e3a8a" + } +}; +const selectedFieldsStyle = { + id: 'selected-fields-fill', + type: 'fill', + paint: { + 'fill-color': "#fca5a5", + 'fill-opacity': 0.5, + 'fill-outline-color': "#1e3a8a" + } +}; +const brpFieldsLineStyle = { + id: 'brp-fields-line', + type: 'line', + paint: { + 'line-color': "#1e3a8a", + 'line-opacity': 0.8, + 'line-width': 2, + } +}; + + +export function FieldsMap(props: FieldsMapType) { + const navigation = useNavigation(); + const fetcher = useFetcher(); + const mapboxToken = props.mapboxToken + + const [bprFieldsData, setBrpFieldsData] = useState(null); + const [selectedFieldsData, setSelectedFieldsData] = useState(null); + + async function loadBrpFields(evt) { + + // Check if user zoomed in enough + const zoom = evt.target.getZoom() + if (zoom >= 12) { + + // Get the bounding box of the map view + const bbox = evt.target.getBounds(0.5) + + const formBrpFields = new FormData(); + formBrpFields.append("question", 'get_brp_fields') + formBrpFields.append("xmax", bbox.getEast()) + formBrpFields.append("xmin", bbox.getWest()) + formBrpFields.append("ymax", bbox.getNorth()) + formBrpFields.append("ymin", bbox.getSouth()) + + await fetcher.submit(formBrpFields, { + method: "POST" + }) + const brpFields = await fetcher.data + setBrpFieldsData(brpFields) + } + } + + function handleClickOnField(evt) { + if (evt.features && evt.features[0].properties) { + + const feature = { + type: evt.features[0].type, + geometry: evt.features[0].geometry, + properties: evt.features[0].properties + } + + if (selectedFieldsData) { + // Check if field is already selected + const b_id = feature.properties.reference_id + const featuresOld = selectedFieldsData.features + + const featureToRemove = featuresOld.find(f => f.properties.reference_id === b_id) + + if (featureToRemove) { + // Remove field from selection + const featuresWithRemoval = featuresOld.filter(f => f.properties.reference_id !== b_id) + const featureCollection = { + type: "FeatureCollection", + features: featuresWithRemoval + } + setSelectedFieldsData(featureCollection) + } else { + // Add field to selection + const featureCollection = { + type: "FeatureCollection", + features: [ + ...featuresOld, + feature + ] + } + setSelectedFieldsData(featureCollection) + } + } else { + // Create selection with first field + const featureCollection = { + type: "FeatureCollection", + features: [ + feature + ] + } + setSelectedFieldsData(featureCollection) + } + } + } + + async function handleClickOnSubmit() { + + selectedFieldsData + + const formSelectedFields = new FormData(); + formSelectedFields.append("question", 'submit_selected_fields') + formSelectedFields.append("selected_fields", JSON.stringify(selectedFieldsData.features)) + + await fetcher.submit(formSelectedFields, { + method: "POST", + }) + + + } + + return ( +
+ await loadBrpFields(evt)} + onMoveEnd={async evt => await loadBrpFields(evt)} + onClick={evt => handleClickOnField(evt)} + interactiveLayerIds={['brp-fields-fill', 'selected-fields-fill', 'brp-fields-line']} + > + + + + + + + {/* */} + + + + +
+ +
+
+ ) +} + diff --git a/fdm-app/app/components/blocks/fields.tsx b/fdm-app/app/components/blocks/fields.tsx new file mode 100644 index 000000000..0befbaac9 --- /dev/null +++ b/fdm-app/app/components/blocks/fields.tsx @@ -0,0 +1,153 @@ +import { Form, useNavigation } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import type { FeatureCollection } from "geojson"; + +// Components +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast" +import { FieldMap } from "@/components/blocks/field-map"; +import { ClientOnly } from "remix-utils/client-only"; +import { Skeleton } from "../ui/skeleton"; + + +export interface soilTypesListType { + value: string + label: string +} + +export interface fieldType { + /** Mapbox API token for map rendering */ + mapboxToken: string; + /** Unique identifier for the field */ + b_id: string + /** Display name of the field */ + b_name: string + /** Area of the field in hectares */ + b_area: number | null + /** Agricultural soil type classification */ + b_soiltype_agr: string | null + b_geojson: FeatureCollection + action: string +} + +export interface fieldsType { + fields: fieldType[] + mapboxToken: string + action: string +} + +export function Fields(props: fieldsType) { + return ( +
+ {props.fields.map(field => { + return ( +
+ +
+ ) + })} +
+ ) +} + +function Field(props: fieldType) { + const navigation = useNavigation() + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = useState(false) + + useEffect(() => { + if (navigation.state === "idle" && isSubmitting) { + toast({ + title: "Perceel is bijgewerkt", + description: "", + }); + setIsSubmitting(false); + } + }, [navigation.state, isSubmitting, toast]) + + function handleSubmit() { + setIsSubmitting(true); + } + + return ( +
+ +
+ + {/* {props.b_name} + {props.b_area} ha */} + + +
+
+
+ +
+
+ + +
+
+ + +
+
+
+ + } > + {() => + } + +
+
+
+ + + +
+
+
+ ) +} diff --git a/fdm-app/app/components/blocks/missing-farm.tsx b/fdm-app/app/components/blocks/missing-farm.tsx new file mode 100644 index 000000000..f46722ffb --- /dev/null +++ b/fdm-app/app/components/blocks/missing-farm.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; + +export default function MissingFarm() { + + return ( + <> +
+
+

+ Het lijkt erop dat je nog geen bedrijf hebt :( +

+

+ Gebruik onze wizard en maak snel je eigen bedrijf aan +

+
+ +

+ De meeste gebruikers lukt het binnen 6 minuten. +

+
+ + ) +} diff --git a/fdm-app/app/components/custom/multi-select.tsx b/fdm-app/app/components/custom/multi-select.tsx new file mode 100644 index 000000000..d04b6a0a0 --- /dev/null +++ b/fdm-app/app/components/custom/multi-select.tsx @@ -0,0 +1,385 @@ +// https://github.com/sersavan/shadcn-multi-select-component + +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + CheckIcon, + XCircle, + ChevronDown, + XIcon, + WandSparkles, +} from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { Separator } from "@/components/ui/separator"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; + +/** + * Variants for the multi-select component to handle different styles. + * Uses class-variance-authority (cva) to define different styles based on "variant" prop. + */ +const multiSelectVariants = cva( + "m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", + { + variants: { + variant: { + default: + "border-foreground/10 text-foreground bg-card hover:bg-card/80", + secondary: + "border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + inverted: "inverted", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +/** + * Props for MultiSelect component + */ +interface MultiSelectProps + extends React.ButtonHTMLAttributes, + VariantProps { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + /** Optional icon component to display alongside the option. */ + icon?: React.ComponentType<{ className?: string }>; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * Animation duration in seconds for the visual effects (e.g., bouncing badges). + * Optional, defaults to 0 (no animation). + */ + animation?: number; + + /** + * Maximum number of items to display. Extra selected items will be summarized. + * Optional, defaults to 3. + */ + maxCount?: number; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; + + name?: string; +} + +export const MultiSelect = React.forwardRef< + HTMLButtonElement, + MultiSelectProps +>( + ( + { + options, + onValueChange, + variant, + defaultValue = [], + placeholder = "Select opties", + animation = 0, + maxCount = 10, + modalPopover = false, + // asChild = false, + className, + name = 'multi-select', + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = + React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [isAnimating, setIsAnimating] = React.useState(false); + + const handleInputKeyDown = ( + event: React.KeyboardEvent + ) => { + if (event.key === "Enter") { + setIsPopoverOpen(true); + } else if (event.key === "Backspace" && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const clearExtraOptions = () => { + const newSelectedValues = selectedValues.slice(0, maxCount); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + return ( + + + + + setIsPopoverOpen(false)} + > + + + + Geen resultaten gevonden. + + +
+ +
+ Selecteer alles +
+ {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + className="cursor-pointer" + > +
+ +
+ {option.icon && ( + + )} + {option.label} +
+ ); + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Wissen + + + + )} + setIsPopoverOpen(false)} + className="flex-1 justify-center cursor-pointer max-w-full" + > + Sluiten + +
+
+
+
+
+ {animation > 0 && selectedValues.length > 0 && ( + setIsAnimating(!isAnimating)} + /> + )} +
+ ); + } +); + +MultiSelect.displayName = "MultiSelect"; \ No newline at end of file diff --git a/fdm-app/app/components/ui/avatar.tsx b/fdm-app/app/components/ui/avatar.tsx new file mode 100644 index 000000000..706f1778b --- /dev/null +++ b/fdm-app/app/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "~/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/fdm-app/app/components/ui/badge.tsx b/fdm-app/app/components/ui/badge.tsx new file mode 100644 index 000000000..5e2b7aca7 --- /dev/null +++ b/fdm-app/app/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/fdm-app/app/components/ui/breadcrumb.tsx b/fdm-app/app/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..035b8284d --- /dev/null +++ b/fdm-app/app/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { Slot } from "@radix-ui/react-slot" + +import { cn } from "~/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>