This commit is contained in:
PxlLoewe
2025-04-21 15:07:56 -07:00
11 changed files with 513 additions and 75 deletions

View File

@@ -0,0 +1,119 @@
"use client";
import {
FieldValues,
Path,
RegisterOptions,
UseFormReturn,
} from "react-hook-form";
import SelectTemplate, {
Props as SelectTemplateProps,
StylesConfig,
} from "react-select";
import { cn } from "helpers/cn";
import dynamic from "next/dynamic";
import { CSSProperties } from "react";
interface SelectProps<T extends FieldValues>
extends Omit<SelectTemplateProps, "form"> {
label?: any;
name: Path<T>;
form: UseFormReturn<T> | any;
formOptions?: RegisterOptions<T>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
const customStyles: StylesConfig<any, false> = {
control: (provided) => ({
...provided,
backgroundColor: "var(--color-base-100)",
borderColor: "color-mix(in oklab, var(--color-base-content) 20%, #0000);",
borderRadius: "0.5rem",
padding: "0.25rem",
boxShadow: "none",
"&:hover": {
borderColor: "color-mix(in oklab, var(--color-base-content) 20%, #0000);",
},
}),
option: (provided, state) => ({
...provided,
backgroundColor: state.isSelected ? "hsl(var(--p))" : "hsl(var(--b1))",
color: "var(--color-primary-content)",
"&:hover": { backgroundColor: "var(--color-base-200)" }, // DaisyUI secondary color
}),
multiValueLabel: (provided) => ({
...provided,
color: "var(--color-primary-content)",
}),
singleValue: (provided) => ({
...provided,
color: "var(--color-primary-content)",
}),
multiValue: (provided) => ({
...provided,
backgroundColor: "var(--color-base-300)",
}),
menu: (provided) => ({
...provided,
backgroundColor: "var(--color-base-100)",
borderRadius: "0.5rem",
}),
};
const SelectCom = <T extends FieldValues>({
name,
label = name,
placeholder = label,
form,
formOptions,
className,
...inputProps
}: SelectProps<T>) => {
return (
<div>
<span className="label-text text-lg flex items-center gap-2">
{label}
</span>
<SelectTemplate
onChange={(newValue: any) => {
if (Array.isArray(newValue)) {
form.setValue(name, newValue.map((v: any) => v.value) as any, {
shouldDirty: true,
});
} else {
form.setValue(name, newValue.value, {
shouldDirty: true,
});
}
form.trigger(name);
form.Dirty;
}}
value={
(inputProps as any)?.isMulti
? (inputProps as any).options?.filter((o: any) =>
form.watch(name)?.includes(o.value),
)
: (inputProps as any).options?.find(
(o: any) => o.value === form.watch(name),
)
}
styles={customStyles as any}
className={cn("w-full placeholder:text-neutral-600", className)}
placeholder={placeholder}
{...inputProps}
/>
{form.formState.errors[name]?.message && (
<p className="text-error">
{form.formState.errors[name].message as string}
</p>
)}
</div>
);
};
const SelectWrapper = <T extends FieldValues>(props: SelectProps<T>) => (
<SelectCom {...props} />
);
export const Select = dynamic(() => Promise.resolve(SelectWrapper), {
ssr: false,
});

View File

@@ -58,6 +58,13 @@ interface MapStore {
aircraftId: string, aircraftId: string,
tab: MapStore["aircraftTabs"][string], tab: MapStore["aircraftTabs"][string],
) => void; ) => void;
missionTabs: {
[missionId: string]: "home" | "details" | "chat";
};
setMissionTab: (
missionId: string,
tab: MapStore["missionTabs"][string],
) => void;
} }
export const useMapStore = create<MapStore>((set, get) => ({ export const useMapStore = create<MapStore>((set, get) => ({
@@ -106,4 +113,12 @@ export const useMapStore = create<MapStore>((set, get) => ({
[aircraftId]: tab, [aircraftId]: tab,
}, },
})), })),
missionTabs: {},
setMissionTab: (missionId, tab) =>
set((state) => ({
missionTabs: {
...state.missionTabs,
[missionId]: tab,
},
})),
})); }));

View File

@@ -6,6 +6,7 @@ interface MissionStore {
missions: MissionOptionalDefaults[]; missions: MissionOptionalDefaults[];
setMissions: (missions: MissionOptionalDefaults[]) => void; setMissions: (missions: MissionOptionalDefaults[]) => void;
getMissions: () => Promise<undefined>; getMissions: () => Promise<undefined>;
setMission: (mission: MissionOptionalDefaults) => void;
} }
export const useMissionsStore = create<MissionStore>((set) => ({ export const useMissionsStore = create<MissionStore>((set) => ({
@@ -47,4 +48,17 @@ export const useMissionsStore = create<MissionStore>((set) => ({
set({ missions: data }); set({ missions: data });
return undefined; return undefined;
}, },
setMission: (mission) =>
set((state) => {
const existingMissionIndex = state.missions.findIndex(
(m) => m.id === mission.id,
);
if (existingMissionIndex !== -1) {
const updatedMissions = [...state.missions];
updatedMissions[existingMissionIndex] = mission;
return { missions: updatedMissions };
} else {
return { missions: [...state.missions, mission] };
}
}),
})); }));

View File

@@ -7,6 +7,6 @@ interface PannelStore {
} }
export const usePannelStore = create<PannelStore>((set) => ({ export const usePannelStore = create<PannelStore>((set) => ({
isOpen: false, isOpen: true, // DEBUG, REMOVE LATER FOR PROD
setOpen: (isOpen) => set({ isOpen }), setOpen: (isOpen) => set({ isOpen }),
})); }));

View File

@@ -2,11 +2,19 @@ import { useMissionsStore } from "_store/missionsStore";
import { Marker, Popup, useMap } from "react-leaflet"; import { Marker, Popup, useMap } from "react-leaflet";
import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet"; import { DivIcon, Marker as LMarker, Popup as LPopup } from "leaflet";
import { useMapStore } from "_store/mapStore"; import { useMapStore } from "_store/mapStore";
import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { Cross, House, Minimize2, Route } from "lucide-react"; import { Cross, House, Minimize2, Route } from "lucide-react";
import { SmartPopup, useConflict, useSmartPopup } from "_components/SmartPopup"; import { SmartPopup, useConflict, useSmartPopup } from "_components/SmartPopup";
import { Mission, MissionState } from "@repo/db"; import { Mission, MissionState } from "@repo/db";
import FMSStatusHistory from "./_components/MissionMarkerTabs";
export const MISSION_STATUS_COLORS: Record<MissionState, string> = { export const MISSION_STATUS_COLORS: Record<MissionState, string> = {
draft: "#0092b8", draft: "#0092b8",
@@ -21,10 +29,38 @@ export const MISSION_STATUS_TEXT_COLORS: Record<MissionState, string> = {
}; };
const MissionPopupContent = ({ mission }: { mission: Mission }) => { const MissionPopupContent = ({ mission }: { mission: Mission }) => {
const setMissionTab = useMapStore((state) => state.setMissionTab);
const currentTab = useMapStore(
(state) => state.missionTabs[mission.id] || "home",
);
const handleTabChange = useCallback(
(tab: "home" | "details" | "chat") => {
if (currentTab !== tab) {
setMissionTab(mission.id, tab);
}
},
[currentTab, mission.id, setMissionTab],
);
const renderTabContent = useMemo(() => {
switch (currentTab) {
case "home":
return <FMSStatusHistory />;
case "details":
return <div>Details Content</div>;
case "chat":
return <div>Chat Content</div>;
default:
return <span className="text-gray-100">Error</span>;
}
}, [currentTab]);
const setOpenMissionMarker = useMapStore( const setOpenMissionMarker = useMapStore(
(state) => state.setOpenMissionMarker, (state) => state.setOpenMissionMarker,
); );
const { anchor } = useSmartPopup(); const { anchor } = useSmartPopup();
return ( return (
<> <>
<div <div
@@ -36,7 +72,7 @@ const MissionPopupContent = ({ mission }: { mission: Mission }) => {
}); });
}} }}
> >
<Minimize2 className="text-white " size={15} /> <Minimize2 className="text-white" />
</div> </div>
<div <div
@@ -68,40 +104,47 @@ const MissionPopupContent = ({ mission }: { mission: Mission }) => {
}} }}
> >
<div <div
className="p-2 flex justify-center items-center" className="p-2 px-3 flex justify-center items-center cursor-pointer"
style={{ style={{
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`, backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
borderBottom:
currentTab === "home"
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
: "5px solid transparent",
}} }}
onClick={() => handleTabChange("home")}
> >
<House className="text-sm " /> <House className="text-sm" />
</div> </div>
<div <div
className="p-2 flex justify-center items-center" className="p-2 px-4 flex justify-center items-center cursor-pointer"
style={{ style={{
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`, backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
borderBottom:
currentTab === "details"
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
: "5px solid transparent",
}} }}
onClick={() => handleTabChange("details")}
> >
<Route className="text-sm " /> <Route className="text-sm" />
</div> </div>
<div <div
className="flex justify-center items-center text-2xl p-2" className="p-2 px-4 flex justify-center items-center cursor-pointer"
style={{ style={{
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`, backgroundColor: `${MISSION_STATUS_COLORS[mission.state]}`,
color: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`, borderBottom:
currentTab === "chat"
? `5px solid ${MISSION_STATUS_TEXT_COLORS[mission.state]}`
: "5px solid transparent",
}} }}
onClick={() => handleTabChange("chat")}
> >
{mission.state} <Cross className="text-sm" />
</div>
<div
className="p-2 flex-1 flex justify-center items-center"
style={{
backgroundColor: `${MISSION_STATUS_TEXT_COLORS[mission.state]}`,
}}
>
<span className="text-sm text-white">Einsatz 250411</span>
</div> </div>
</div> </div>
</div> </div>
<div>{renderTabContent}</div>
</> </>
); );
}; };
@@ -231,9 +274,11 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
closeOnClick={false} closeOnClick={false}
autoPan={false} autoPan={false}
wrapperClassName="relative" wrapperClassName="relative"
className="w-[300px] h-[150px]" className="w-[502px]"
> >
<MissionPopupContent mission={mission} /> <div style={{ height: "auto", maxHeight: "90vh", overflowY: "auto" }}>
<MissionPopupContent mission={mission} />
</div>
</SmartPopup> </SmartPopup>
)} )}
</Fragment> </Fragment>

View File

@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { FMS_STATUS_TEXT_COLORS } from "../AircraftMarker";
const FMSStatusHistory = () => {
const dummyData = [
{ status: "3", time: "12:00", unit: "RTW", unitshort: "RTW" },
{ status: "3", time: "12:01", unit: "Christoph 31", unitshort: "CHX31" },
{ status: "4", time: "12:09", unit: "RTW", unitshort: "RTW" },
{ status: "4", time: "12:11", unit: "Christoph 31", unitshort: "CHX31" },
{ status: "7", time: "12:34", unit: "RTW", unitshort: "RTW" },
{ status: "1", time: "12:38", unit: "Christoph 31", unitshort: "CHX31" },
];
return (
<div className="p-4">
<div className="flex items-center gap-2">
{[
...new Map(dummyData.map((entry) => [entry.unit, entry])).values(),
].map((entry, index, array) => (
<React.Fragment key={index}>
<div className="flex items-center gap-2">
<span
className="font-bold text-base"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.status],
}}
>
{entry.status}
</span>
<span className="text-base-content">{entry.unitshort}</span>
</div>
{index < array.length - 1 && <span className="mx-1">|</span>}
</React.Fragment>
))}
</div>
<div className="divider m-0" />
<ul className="space-y-2">
{dummyData.map((entry, index) => (
<li key={index} className="flex items-center gap-2">
<span className="text-base-content">{entry.time}</span>
<span
className="font-bold text-base"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.status],
}}
>
{entry.status}
</span>
<span className="text-base-content">{entry.unit}</span>
</li>
))}
</ul>
</div>
);
};
export default FMSStatusHistory;

View File

@@ -0,0 +1,215 @@
"use client";
import React, { useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { MissionSchema } from "@repo/db/zod";
import { BellRing, BookmarkPlus, Trash2 } from "lucide-react";
import { Select } from "_components/Select";
const clearBtn = () => {
return (
<button className="btn btn-sm btn-circle btn-info">
<Trash2 className="w-4 h-4" />
</button>
);
};
const missionFormSchema = MissionSchema.pick({
addressLat: true,
addressLng: true,
addressStreet: true,
addressCity: true,
addressZip: true,
missionCategory: true,
missionKeyword: true,
missionAdditionalInfo: true,
missionPatientInfo: true,
});
type MissionFormValues = z.infer<typeof missionFormSchema>;
const dummyRettungsmittel = [
"RTW",
"Feuerwehr",
"Polizei",
"Christoph 31",
"Christoph 100",
"Christoph Berlin",
"Christophorus 1",
];
export const MissionForm: React.FC = () => {
const [missionCategory, setMissionCategory] = useState<"PRIMÄR" | "SEKUNDÄR">(
"PRIMÄR",
);
const [missionKeyword, setMissionKeyword] = useState<
"AB_ATMUNG" | "C_BLUTUNG"
>("AB_ATMUNG");
const [missionType, setMissionType] = useState<"typ1" | "typ2" | "typ3">(
"typ1",
);
const [selectedRettungsmittel, setSelectedRettungsmittel] = useState<
{ label: string; value: string }[]
>([]);
const form = useForm<MissionFormValues>({
resolver: zodResolver(missionFormSchema),
defaultValues: {
addressLat: 0,
addressLng: 0,
missionCategory: "PRIMÄR",
},
});
const onSubmit = (data: MissionFormValues) => {
console.log({
...data,
rettungsmittel: selectedRettungsmittel.map((item) => item.value),
});
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Koorinaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Koordinaten</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
{...form.register("addressLat")}
className="input input-sm input-neutral input-bordered w-full"
readOnly
/>
<input
type="text"
{...form.register("addressLng")}
className="input input-sm input-neutral input-bordered w-full"
readOnly
/>
</div>
</div>
{/* Adresse Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Adresse</h2>
<input
type="text"
{...form.register("addressStreet")}
placeholder="Straße"
className="input input-primary input-bordered w-full mb-4"
/>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
{...form.register("addressCity")}
placeholder="Stadt"
className="input input-primary input-bordered w-full"
/>
<input
type="text"
{...form.register("addressZip")}
placeholder="PLZ"
className="input input-primary input-bordered w-full"
/>
</div>
<input
type="text"
{...form.register("missionAdditionalInfo")}
placeholder="Zusätzliche Adressinformationen"
className="input input-primary input-bordered w-full mt-4"
/>
</div>
{/* Rettungsmittel Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Rettungsmittel</h2>
<Select
name="rettungsmittel"
label={""}
isMulti
form={form}
options={dummyRettungsmittel.map((key, val) => ({
label: key,
value: val,
}))}
/>
</div>
{/* Einsatzdaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Einsatzdaten</h2>
<select
{...form.register("missionCategory")}
className="select select-primary select-bordered w-full mb-4"
onChange={(e) =>
setMissionCategory(e.target.value as "PRIMÄR" | "SEKUNDÄR")
}
>
<option value="PRIMÄR">PRIMÄR</option>
<option value="SEKUNDÄR">SEKUNDÄR</option>
</select>
<select
{...form.register("missionKeyword")}
className="select select-primary select-bordered w-full mb-4"
onChange={(e) =>
setMissionKeyword(e.target.value as "AB_ATMUNG" | "C_BLUTUNG")
}
>
<option value="AB_ATMUNG">AB_ATMUNG</option>
<option value="C_BLUTUNG">C_BLUTUNG</option>
</select>
<select
/* {...form.register("missionKeyword")} */
className="select select-primary select-bordered w-full mb-4"
onChange={(e) =>
setMissionType(e.target.value as "typ1" | "typ2" | "typ3")
}
>
<option defaultChecked disabled value="">
Einsatz Szenerie auswählen...
</option>
<option value="typ1">typ1</option>
<option value="typ2">typ2</option>
<option value="typ3">typ3</option>
</select>
<textarea
{...form.register("missionAdditionalInfo")}
placeholder="Einsatzinformationen"
className="textarea textarea-primary textarea-bordered w-full mb-4"
/>
{missionCategory === "SEKUNDÄR" && (
<input
type="text"
placeholder="Zielkrankenhaus"
className="input input-primary input-bordered w-full"
/>
)}
</div>
{/* Patienteninformationen Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
<textarea
{...form.register("missionPatientInfo")}
placeholder="Patienteninformationen"
className="textarea textarea-primary textarea-bordered w-full"
/>
</div>
<p className="text-sm text-error">
Du musst noch ein Gebäude auswählen, um den Einsatz zu erstellen.
</p>
<div className="form-control min-h-[140px] max-w-[320px]">
<div className="flex gap-2">
<button type="submit" className="btn btn-warning">
<BellRing className="h-4 w-4" /> Alarmieren
</button>
<button type="submit" className="btn btn-primary btn-block">
<BookmarkPlus className="h-5 w-5" /> Einsatz vorbereiten
</button>
</div>
</div>
</form>
);
};

View File

@@ -1,34 +0,0 @@
import { useMissionsStore } from "_store/missionsStore";
export const Missions = () => {
const { missions } = useMissionsStore();
return (
<div>
<table className="table table-xs">
<thead>
<tr>
<th>ID</th>
<th>Einsatzmittel</th>
<th>Ort</th>
<th></th>
</tr>
</thead>
<tbody>
{missions.map((mission) => (
<tr key={mission.id}>
<td>{mission.id}</td>
<td>{mission.missionCategory}</td>
<td>
{mission.addressStreet}, {mission.addressCity}
</td>
<td>
<button className="btn btn-sm">Details</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

View File

@@ -14,7 +14,7 @@ export const OpenButton = () => {
isOpen && "transform translate-x-full", isOpen && "transform translate-x-full",
)} )}
> >
Open Neuer Einsatz
</button> </button>
); );
}; };

View File

@@ -1,19 +1,25 @@
import { Missions } from "dispatch/_components/pannel/Missions";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import { MissionForm } from "./MissionForm";
import { Rss } from "lucide-react";
export const Pannel = () => { export const Pannel = () => {
const { isOpen, setOpen } = usePannelStore(); const { isOpen, setOpen } = usePannelStore();
return ( return (
<div className={cn("flex-1 max-w-[400px] z-9999999")}> <div className={cn("flex-1 max-w-[600px] z-9999999")}>
<div className="bg-base-100 h-full w-full"> <div className="bg-base-100 min-h-screen h-full max-h-screen w-full overflow-auto">
<div className="flex justify-between items-center p-4"> <div className="flex flex-row justify-between items-center p-4">
<h1 className="text-xl font-bold">Pannel</h1> <h1 className="text-xl font-bold flex items-center gap-2">
<Rss /> Neuer Einsatz
</h1>
<button className="btn" onClick={() => setOpen(false)}> <button className="btn" onClick={() => setOpen(false)}>
Close Abbrechen
</button> </button>
</div> </div>
<Missions /> <div className="divider m-0" />
<div className="p-4">
<MissionForm />
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,7 +2,6 @@
import { OpenButton } from "dispatch/_components/pannel/OpenButton"; import { OpenButton } from "dispatch/_components/pannel/OpenButton";
import { Pannel } from "dispatch/_components/pannel/Pannel"; import { Pannel } from "dispatch/_components/pannel/Pannel";
import MapToastCard2 from "dispatch/_components/toast/ToastCard";
import { usePannelStore } from "_store/pannelStore"; import { usePannelStore } from "_store/pannelStore";
import { cn } from "helpers/cn"; import { cn } from "helpers/cn";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@@ -11,19 +10,20 @@ const Map = dynamic(() => import("./_components/map/Map"), { ssr: false });
export default () => { export default () => {
const { isOpen } = usePannelStore(); const { isOpen } = usePannelStore();
return ( return (
<div <div className="relative flex-1 flex transition-all duration-500 ease w-full">
className={cn(
"relative flex-1 flex transition-all duration-500 ease",
!isOpen && "w-[calc(100%+400px)]",
isOpen && "w-[100%]",
)}
>
{/* <MapToastCard2 /> */} {/* <MapToastCard2 /> */}
<div className="flex-1 relative flex"> <div className="flex flex-1 relative">
<OpenButton /> <OpenButton />
<Map /> <Map />
</div> </div>
<Pannel /> <div
className={cn(
"absolute right-0 w-[500px] z-999 transition-transform",
isOpen ? "translate-x-0" : "translate-x-full",
)}
>
<Pannel />
</div>
</div> </div>
); );
}; };