Merge pull request #74 from VAR-Virtual-Air-Rescue/staging

release V2.0
This commit was merged in pull request #74.
This commit is contained in:
PxlLoewe
2025-07-22 09:05:14 -07:00
committed by GitHub
58 changed files with 1433 additions and 3254 deletions

View File

@@ -2,5 +2,6 @@
"tabWidth": 2,
"useTabs": true,
"printWidth": 100,
"singleQuote": false
"singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

View File

@@ -2,6 +2,39 @@ import { MissionLog, NotificationPayload, prisma } from "@repo/db";
import { io } from "index";
import cron from "node-cron";
const removeMission = async (id: number, reason: string) => {
const log: MissionLog = {
type: "completed-log",
auto: true,
timeStamp: new Date().toISOString(),
data: {},
};
const updatedMission = await prisma.mission.update({
where: {
id: id,
},
data: {
state: "finished",
missionLog: {
push: log as any,
},
},
});
io.to("dispatchers").emit("new-mission", { updatedMission });
io.to("dispatchers").emit("notification", {
type: "mission-auto-close",
status: "chron",
message: `Einsatz ${updatedMission.publicId} wurde aufgrund ${reason} geschlossen.`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
console.log(`Mission ${updatedMission.id} closed due to inactivity.`);
};
const removeClosedMissions = async () => {
const oldMissions = await prisma.mission.findMany({
where: {
@@ -15,18 +48,6 @@ const removeClosedMissions = async () => {
const lastAlertTime = lastAlert ? new Date(lastAlert.timeStamp) : null;
const aircraftsInMission = await prisma.connectedAircraft.findMany({
where: {
stationId: {
in: mission.missionStationIds,
},
},
});
const allConnectedAircraftsInIdleStatus = aircraftsInMission.every((a) =>
["1", "2", "6"].includes(a.fmsStatus),
);
const allStationsInMissionChangedFromStatus4to1Or8to1 = mission.missionStationIds.every(
(stationId) => {
const status4Log = (mission.missionLog as unknown as MissionLog[]).findIndex((l) => {
@@ -69,67 +90,24 @@ const removeClosedMissions = async () => {
},
);
const missionHastManualReactivation = (mission.missionLog as unknown as MissionLog[]).some(
const missionHasManualReactivation = (mission.missionLog as unknown as MissionLog[]).some(
(l) => l.type === "reopened-log",
);
console.log({
missionId: mission.publicId,
allConnectedAircraftsInIdleStatus,
lastAlertTime,
allStationsInMissionChangedFromStatus4to1Or8to1,
missionHastManualReactivation,
});
if (
!allConnectedAircraftsInIdleStatus // If some aircrafts are still active, do not close the mission
)
return;
const now = new Date();
if (missionHasManualReactivation) return;
if (!lastAlertTime) return;
// Case 1: Forgotten Mission, last alert more than 3 Hours ago
// Case 2: All stations in mission changed from status 4 to 1 or from status 8 to 1
if (
!(
now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180 ||
allStationsInMissionChangedFromStatus4to1Or8to1
) ||
missionHastManualReactivation
)
return;
const now = new Date();
if (now.getTime() - lastAlertTime.getTime() > 1000 * 60 * 180)
return removeMission(mission.id, "inaktivität");
const log: MissionLog = {
type: "completed-log",
auto: true,
timeStamp: new Date().toISOString(),
data: {},
};
const updatedMission = await prisma.mission.update({
where: {
id: mission.id,
},
data: {
state: "finished",
missionLog: {
push: log as any,
},
},
});
io.to("dispatchers").emit("new-mission", { updatedMission });
io.to("dispatchers").emit("notification", {
type: "mission-auto-close",
status: "chron",
message: `Einsatz ${updatedMission.publicId} wurde aufgrund ${allStationsInMissionChangedFromStatus4to1Or8to1 ? "des Freimeldens aller Stationen" : "von Inaktivität"} geschlossen.`,
data: {
missionId: updatedMission.id,
publicMissionId: updatedMission.publicId,
},
} as NotificationPayload);
console.log(`Mission ${mission.id} closed due to inactivity.`);
// Case 2: All stations in mission changed from status 4 to 1/6 or from status 8 to 1/6
if (allStationsInMissionChangedFromStatus4to1Or8to1)
return removeMission(mission.id, "dem freimelden aller Stationen");
});
};
const removeConnectedAircrafts = async () => {
const connectedAircrafts = await prisma.connectedAircraft.findMany({
where: {

View File

@@ -8,31 +8,30 @@
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"typescript": "latest"
},
"dependencies": {
"@socket.io/redis-adapter": "^8.3.0",
"axios": "^1.9.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"discord.js": "^14.19.3",
"dotenv": "^16.5.0",
"cron": "^4.3.2",
"discord.js": "^14.21.0",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"node-cron": "^4.1.0",
"node-cron": "^4.2.1",
"nodemon": "^3.1.10",
"prom-client": "^15.1.3",
"react": "^19.1.0",
"redis": "^5.1.1",
"redis": "^5.6.0",
"socket.io": "^4.8.1",
"tsx": "^4.19.4"
"tsx": "^4.20.3"
}
}

View File

@@ -19,7 +19,6 @@ router.post("/set-standard-name", async (req, res) => {
id: userId,
},
});
console.log(`Setting standard name for user ${userId} (${user?.publicId}) to member ${memberId}`);
if (!user) {
res.status(404).json({ error: "User not found" });
return;

View File

@@ -8,39 +8,39 @@
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"@repo/db": "workspace:*",
"@repo/shared-components": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/cookie-parser": "^1.4.9",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"typescript": "latest"
},
"dependencies": {
"@react-email/components": "^0.0.41",
"@redis/json": "^5.1.1",
"@react-email/components": "^0.3.2",
"@redis/json": "^5.6.0",
"@socket.io/redis-adapter": "^8.3.0",
"@types/jsonwebtoken": "^9.0.9",
"axios": "^1.9.0",
"@types/jsonwebtoken": "^9.0.10",
"axios": "^1.10.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dotenv": "^16.5.0",
"cron": "^4.3.2",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2",
"livekit-server-sdk": "^2.13.0",
"node-cron": "^4.1.0",
"nodemailer": "^7.0.3",
"livekit-server-sdk": "^2.13.1",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.5",
"nodemon": "^3.1.10",
"prom-client": "^15.1.3",
"react": "^19.1.0",
"redis": "^5.1.1",
"redis": "^5.6.0",
"socket.io": "^4.8.1",
"tsx": "^4.19.4"
"tsx": "^4.20.3"
}
}

View File

@@ -261,9 +261,7 @@ router.post("/:id/hpg-validation-result", async (req, res) => {
},
} as NotificationPayload);
console.log("Got positiv validation Result", result.alertWhenValid);
if (result.alertWhenValid) {
console.log(req.user);
sendAlert(Number(missionId), {}, "HPG");
}
} else {

View File

@@ -45,15 +45,10 @@ export const handleConnectDispatch =
});
}
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedDispatcherEntry = await prisma.connectedDispatcher.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime:
logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes)
: null,
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
lastHeartbeat: new Date().toISOString(),
userId: user.id,
zone: selectedZone,

View File

@@ -73,15 +73,11 @@ export const handleConnectPilot =
}
const randomPos = debug ? getRandomGermanPosition() : undefined;
const [logoffHours, logoffMinutes] = logoffTime.split(":").map(Number);
const connectedAircraftEntry = await prisma.connectedAircraft.create({
data: {
publicUser: getPublicUser(user) as any,
esimatedLogoutTime:
logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes)
: null,
esimatedLogoutTime: logoffTime.length > 0 ? logoffTime : null,
userId: userId,
stationId: parseInt(stationId),
lastHeartbeat: debug ? nowPlus2h.toISOString() : undefined,

View File

@@ -8,7 +8,6 @@ export const handleSendMessage =
{ userId, message }: { userId: string; message: string },
cb: (err: { error?: string }) => void,
) => {
console.log("send-message", userId, message);
const senderId = socket.data.user.id;
const senderUser = await prisma.user.findUnique({

View File

@@ -107,7 +107,6 @@ export function StationsSelect({
menuPlacement={menuPlacement}
isMulti={isMulti}
onChange={(v) => {
console.log("Selected values:", v);
setValue(v);
if (!isMulti) {
const singleValue = v as string;

View File

@@ -31,9 +31,9 @@ export const ConnectionBtn = () => {
if (!uid) return null;
return (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
<span className="text-error mx-2">{connection.message}</span>
)}
{connection.status == "connected" ? (
@@ -63,11 +63,11 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
<h3 className="mb-5 text-lg font-bold">
Verbunden als <span className="text-info">&lt;{connection.selectedZone}&gt;</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Disponent anmelden</h3>
<h3 className="mb-5 text-lg font-bold">Als Disponent anmelden</h3>
)}
<fieldset className="fieldset w-full">
<label className="floating-label w-full text-base">
@@ -89,8 +89,8 @@ export const ConnectionBtn = () => {
<p className="fieldset-label">Du kannst diese Zeit später noch anpassen.</p>
)}
</fieldset>
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
<div className="modal-action flex w-full justify-between">
<form method="dialog" className="flex w-full justify-between">
<button className="btn btn-soft">Zurück</button>
{connection.status == "connected" ? (
<>
@@ -130,7 +130,15 @@ export const ConnectionBtn = () => {
type="submit"
onSubmit={() => false}
onClick={() => {
connection.connect(uid, form.selectedZone, form.logoffTime);
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
connection.connect(
uid,
form.selectedZone,
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
: "",
);
}}
className="btn btn-soft btn-info"
>

View File

@@ -195,7 +195,7 @@ export const MissionForm = () => {
<form className="space-y-4">
{/* Koorinaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Koordinaten</h2>
<h2 className="mb-2 text-lg font-bold">Koordinaten</h2>
<div className="grid grid-cols-2 gap-4">
<input
type="text"
@@ -219,12 +219,12 @@ export const MissionForm = () => {
{/* Adresse Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Adresse</h2>
<h2 className="mb-2 text-lg font-bold">Adresse</h2>
<input
type="text"
{...form.register("addressStreet")}
placeholder="Straße"
className="input input-primary input-bordered w-full mb-4"
className="input input-primary input-bordered mb-4 w-full"
/>
<div className="grid grid-cols-2 gap-4">
<input
@@ -244,17 +244,16 @@ export const MissionForm = () => {
type="text"
{...form.register("addressAdditionalInfo")}
placeholder="Zusätzliche Adressinformationen"
className="input input-primary input-bordered w-full mt-4"
className="input input-primary input-bordered mt-4 w-full"
/>
</div>
{/* Rettungsmittel Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Rettungsmittel</h2>
<h2 className="mb-2 text-lg font-bold">Rettungsmittel</h2>
<StationsSelect
isMulti
selectedStations={form.watch("missionStationIds")}
onChange={(v) => {
console.log("Selected stations:", v);
form.setValue("missionStationIds", v.selectedStationIds);
form.setValue("hpgAmbulanceState", v.hpgAmbulanceState);
form.setValue("hpgFireEngineState", v.hpgFireEngineState);
@@ -270,10 +269,10 @@ export const MissionForm = () => {
{/* Einsatzdaten Section */}
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Einsatzdaten</h2>
<h2 className="mb-2 text-lg font-bold">Einsatzdaten</h2>
<select
{...form.register("type")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
form.setValue("type", e.target.value as missionType);
if (e.target.value === "primary") {
@@ -295,7 +294,7 @@ export const MissionForm = () => {
<>
<select
{...form.register("missionKeywordCategory")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
form.setValue("missionKeywordCategory", e.target.value as string);
form.setValue("missionKeywordName", null as any);
@@ -316,13 +315,13 @@ export const MissionForm = () => {
))}
</select>
{form.formState.errors.missionKeywordCategory && (
<p className="text-error text-sm mb-4">Bitte wähle eine Kategorie aus.</p>
<p className="text-error mb-4 text-sm">Bitte wähle eine Kategorie aus.</p>
)}
</>
)}
<select
{...form.register("missionKeywordAbbreviation")}
className="select select-primary select-bordered w-full mb-4"
className="select select-primary select-bordered mb-4 w-full"
onChange={(e) => {
const keyword = keywords?.find((k) => k.abreviation === e.target.value);
form.setValue("missionKeywordName", keyword?.name || (null as any));
@@ -344,7 +343,7 @@ export const MissionForm = () => {
))}
</select>
{form.formState.errors.missionKeywordAbbreviation && (
<p className="text-error text-sm mb-4">Bitte wähle ein Stichwort aus.</p>
<p className="text-error mb-4 text-sm">Bitte wähle ein Stichwort aus.</p>
)}
<div className="mb-4">
<select
@@ -364,7 +363,7 @@ export const MissionForm = () => {
form.setValue("missionAdditionalInfo", name || "");
}
}}
className="select select-primary select-bordered w-full mb-2"
className="select select-primary select-bordered mb-2 w-full"
value={form.watch("hpgMissionString") || "please_select"}
>
<option disabled value="please_select">
@@ -383,14 +382,14 @@ export const MissionForm = () => {
})}
</select>
{validationRequired && (
<p className="text-sm text-warning">Szenario wird vor Alarmierung HPG-Validiert.</p>
<p className="text-warning text-sm">Szenario wird vor Alarmierung HPG-Validiert.</p>
)}
</div>
<textarea
{...form.register("missionAdditionalInfo")}
placeholder="Einsatzinformationen"
className="textarea textarea-primary textarea-bordered w-full mb-4"
className="textarea textarea-primary textarea-bordered mb-4 w-full"
/>
{form.watch("type") === "sekundär" && (
<input
@@ -402,7 +401,7 @@ export const MissionForm = () => {
)}
</div>
<div className="form-control">
<h2 className="text-lg font-bold mb-2">Patienteninformationen</h2>
<h2 className="mb-2 text-lg font-bold">Patienteninformationen</h2>
<textarea
{...form.register("missionPatientInfo")}
placeholder="Patienteninformationen"

View File

@@ -7,6 +7,7 @@ import dynamic from "next/dynamic";
import { Chat } from "../../_components/left/Chat";
import { Report } from "../../_components/left/Report";
import { SituationBoard } from "_components/left/SituationBoard";
import { BugReport } from "_components/left/BugReport";
const Map = dynamic(() => import("../../_components/map/Map"), { ssr: false });
@@ -14,16 +15,15 @@ const DispatchPage = () => {
const { isOpen } = usePannelStore();
/* return null; */
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full">
<div className="ease relative flex w-full flex-1 transition-all duration-500">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<div className="relative flex flex-1">
<div className="z-999999 absolute left-0 top-1/2 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat />
<div className="mt-2">
<Report />
</div>
<Report />
<BugReport />
</div>
<div className="absolute left-0 top-19/20 transform -translate-y-1/2 pl-4 z-999999">
<div className="top-19/20 z-999999 absolute left-0 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SituationBoard />
</div>
@@ -32,7 +32,7 @@ const DispatchPage = () => {
</div>
<div
className={cn(
"absolute right-0 w-[500px] z-999 transition-transform",
"z-999 absolute right-0 w-[500px] transition-transform",
isOpen ? "translate-x-0" : "translate-x-full",
)}
>

View File

@@ -27,7 +27,6 @@ export default async function RootLayout({
});
if (!session) {
console.log(session);
return redirect("/logout");
}

View File

@@ -31,7 +31,6 @@ export const useSounds = () => {
const timeouts: NodeJS.Timeout[] = [];
if (page === "new-mission" && newMissionSound.current) {
console.log("new-mission", mission);
newMissionSound.current.currentTime = 0;
newMissionSound.current.play();
if (mission) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -63,9 +63,9 @@ export const ConnectionBtn = () => {
const uid = session.data?.user?.id;
if (!uid) return null;
return (
<div className="rounded-box bg-base-200 flex justify-center items-center gap-2 p-1">
<div className="rounded-box bg-base-200 flex items-center justify-center gap-2 p-1">
{connection.message.length > 0 && (
<span className="mx-2 text-error">{connection.message}</span>
<span className="text-error mx-2">{connection.message}</span>
)}
{connection.status == "connected" ? (
@@ -95,12 +95,12 @@ export const ConnectionBtn = () => {
<dialog ref={modalRef} className="modal">
<div className="modal-box flex flex-col items-center justify-center">
{connection.status == "connected" ? (
<h3 className="text-lg font-bold mb-5">
<h3 className="mb-5 text-lg font-bold">
Verbunden als{" "}
<span className="text-info">&lt;{connection.selectedStation?.bosCallsign}&gt;</span>
</h3>
) : (
<h3 className="text-lg font-bold mb-5">Als Pilot anmelden</h3>
<h3 className="mb-5 text-lg font-bold">Als Pilot anmelden</h3>
)}
{connection.status !== "connected" && (
<div className="w-full">
@@ -135,7 +135,7 @@ export const ConnectionBtn = () => {
/>
</div>
)}
<fieldset className="fieldset w-full mt-2">
<fieldset className="fieldset mt-2 w-full">
<label className="floating-label w-full text-base">
<span>Logoff Zeit (LCL)</span>
<input
@@ -171,8 +171,8 @@ export const ConnectionBtn = () => {
</label>
</fieldset>
)}
<div className="modal-action flex justify-between w-full">
<form method="dialog" className="w-full flex justify-between">
<div className="modal-action flex w-full justify-between">
<form method="dialog" className="flex w-full justify-between">
<button className="btn btn-soft">Zurück</button>
{connection.status == "connected" ? (
<>
@@ -183,7 +183,6 @@ export const ConnectionBtn = () => {
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
console.log(logoffHours, logoffMinutes, form.logoffTime);
await aircraftMutation.mutateAsync({
sessionId: connection.connectedAircraft.id,
change: {
@@ -220,10 +219,15 @@ export const ConnectionBtn = () => {
station.id === parseInt(form.selectedStationId?.toString() || ""),
);
if (selectedStation) {
const [logoffHours, logoffMinutes] =
form.logoffTime?.split(":").map(Number) || [];
await connection.connect(
uid,
form.selectedStationId?.toString() || "",
form.logoffTime || "",
form.logoffTime && logoffHours !== undefined && logoffMinutes !== undefined
? getNextDateWithTime(logoffHours, logoffMinutes).toISOString()
: "",
selectedStation,
session.data!.user,
form.debugPosition,

View File

@@ -12,6 +12,7 @@ import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { checkSimulatorConnected } from "@repo/shared-components";
import { SimConnectionAlert } from "(app)/pilot/_components/SimConnectionAlert";
import { SettingsBoard } from "_components/left/SettingsBoard";
import { BugReport } from "_components/left/BugReport";
const Map = dynamic(() => import("_components/map/Map"), {
ssr: false,
@@ -29,24 +30,23 @@ const PilotPage = () => {
const ownAircraft = aircrafts?.find((aircraft) => aircraft.id === connectedAircraft?.id);
const simulatorConnected = ownAircraft ? checkSimulatorConnected(ownAircraft) : false;
return (
<div className="relative flex-1 flex transition-all duration-500 ease w-full h-screen overflow-hidden">
<div className="ease relative flex h-screen w-full flex-1 overflow-hidden transition-all duration-500">
{/* <MapToastCard2 /> */}
<div className="flex flex-1 relative w-full h-full">
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 pl-4 z-999999">
<div className="relative flex h-full w-full flex-1">
<div className="z-999999 absolute left-0 top-1/2 flex -translate-y-1/2 transform flex-col space-y-2 pl-4">
<Chat />
<div className="mt-2">
<Report />
</div>
<Report />
<BugReport />
</div>
<div className="flex w-2/3 h-full">
<div className="relative flex flex-1 h-full">
<div className="absolute left-0 top-19/20 transform -translate-y-1/2 pl-4 z-999999">
<div className="flex h-full w-2/3">
<div className="relative flex h-full flex-1">
<div className="top-19/20 z-999999 absolute left-0 -translate-y-1/2 transform pl-4">
<div className="flex items-center justify-between gap-4">
<SettingsBoard />
</div>
</div>
<Map />
<div className="absolute top-5 right-10 z-99999 space-y-2">
<div className="z-99999 absolute right-10 top-5 space-y-2">
{!simulatorConnected && status === "connected" && (
<SimConnectionAlert lastUpdated={ownAircraft?.lastHeartbeat} />
)}
@@ -54,19 +54,19 @@ const PilotPage = () => {
</div>
</div>
</div>
<div className="flex w-1/3 h-full">
<div className="flex flex-col w-full h-full p-4 bg-base-300">
<div className="flex h-full w-1/3">
<div className="bg-base-300 flex h-full w-full flex-col p-4">
<h2 className="card-title mb-2">MRT & DME</h2>
<div className="card bg-base-200 shadow-xl mb-4">
<div className="card-body w-full h-full flex items-center justify-center">
<div className=" max-w-150">
<div className="card bg-base-200 mb-4 shadow-xl">
<div className="card-body flex h-full w-full items-center justify-center">
<div className="max-w-150">
<Mrt />
</div>
</div>
</div>
<div className="card bg-base-200 shadow-xl h-1/2 flex">
<div className="card-body w-full h-full p-4 mb-0 flex items-center justify-center">
<div className=" max-w-140">
<div className="card bg-base-200 flex h-1/2 shadow-xl">
<div className="card-body mb-0 flex h-full w-full items-center justify-center p-4">
<div className="max-w-140">
<Dme />
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { Bug } from "lucide-react";
export const BugReport = () => {
return (
<div className="indicator">
<a
className="btn btn-soft btn-sm btn-warning tooltip tooltip-right"
data-tip="Fehler melden"
href="https://discord.com/channels/1077269395019141140/1395892524404576367"
target="_blank"
>
<Bug className="h-4 w-4" />
</a>
</div>
);
};

View File

@@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
export const Chat = () => {
const {
@@ -28,6 +29,7 @@ export const Chat = () => {
const [addTabValue, setAddTabValue] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
const { data: dispatcher } = useQuery({
queryKey: ["dispatcher"],
@@ -51,6 +53,14 @@ export const Chat = () => {
(a) => a.userId !== session.data?.user.id && dispatcherConnected,
);
const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!btnActive) {
setChatOpen(false);
}
}, [btnActive, setChatOpen]);
return (
<div className={cn("dropdown dropdown-right dropdown-center", chatOpen && "dropdown-open")}>
<div className="indicator">
@@ -58,8 +68,12 @@ export const Chat = () => {
<span className="indicator-item status status-info animate-ping"></span>
)}
<button
className="btn btn-soft btn-sm btn-primary"
className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "btn-primary cursor-pointer",
)}
onClick={() => {
if (!btnActive) return;
setReportTabOpen(false);
setChatOpen(!chatOpen);
if (selectedChat) {

View File

@@ -10,6 +10,8 @@ import { useQuery } from "@tanstack/react-query";
import { getConnectedDispatcherAPI } from "_querys/dispatcher";
import { sendReportAPI } from "_querys/report";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useDispatchConnectionStore } from "_store/dispatch/connectionStore";
import { usePilotConnectionStore } from "_store/pilot/connectionStore";
export const Report = () => {
const { setChatOpen, setReportTabOpen, reportTabOpen, setOwnId } = useLeftMenuStore();
@@ -18,6 +20,9 @@ export const Report = () => {
const [selectedPlayer, setSelectedPlayer] = useState<string>("default");
const [message, setMessage] = useState<string>("");
const dispatcherConnected = useDispatchConnectionStore((state) => state.status === "connected");
const pilotConnected = usePilotConnectionStore((state) => state.status === "connected");
useEffect(() => {
if (!session.data?.user.id) return;
setOwnId(session.data.user.id);
@@ -36,6 +41,13 @@ export const Report = () => {
const filteredDispatcher = dispatcher?.filter((d) => d.userId !== session.data?.user.id);
const filteredAircrafts = aircrafts?.filter((a) => a.userId !== session.data?.user.id);
const btnActive = pilotConnected || dispatcherConnected;
useEffect(() => {
if (!btnActive) {
setReportTabOpen(false);
}
}, [btnActive, setReportTabOpen]);
return (
<div
@@ -43,8 +55,12 @@ export const Report = () => {
>
<div className="indicator">
<button
className="btn btn-soft btn-sm btn-error"
className={cn(
"btn btn-soft btn-sm cursor-default",
btnActive && "cursor-pointer btn-error",
)}
onClick={() => {
if (!btnActive) return;
setChatOpen(false);
setReportTabOpen(!reportTabOpen);
}}

View File

@@ -61,8 +61,8 @@ const AircraftPopupContent = ({
return mission ? (
<MissionTab mission={mission} />
) : (
<div className="flex flex-col items-center justify-center min-h-full">
<span className="text-gray-500 my-10 font-semibold">Kein aktiver Einsatz</span>
<div className="flex min-h-full flex-col items-center justify-center">
<span className="my-10 font-semibold text-gray-500">Kein aktiver Einsatz</span>
</div>
);
case "chat":
@@ -77,7 +77,7 @@ const AircraftPopupContent = ({
return (
<>
<div
className="absolute p-1 z-99 top-0 right-0 transform -translate-y-full bg-base-100 cursor-pointer"
className="z-99 bg-base-100 absolute right-0 top-0 -translate-y-full transform cursor-pointer p-1"
onClick={() => {
setOpenAircraftMarker({
open: [],
@@ -90,7 +90,7 @@ const AircraftPopupContent = ({
<div
className={cn(
"absolute w-[calc(100%+2px)] h-4 z-99", // As offset is 2px, we need to add 2px to the width
"z-99 absolute h-4 w-[calc(100%+2px)]", // As offset is 2px, we need to add 2px to the width
anchor.includes("left") ? "-left-[2px]" : "-right-[2px]",
anchor.includes("top") ? "-top-[2px]" : "-bottom-[2px]",
)}
@@ -111,13 +111,13 @@ const AircraftPopupContent = ({
/>
<div>
<div
className="flex gap-[2px] text-white pb-0.5 overflow-auto"
className="flex gap-[2px] overflow-auto pb-0.5 text-white"
style={{
backgroundColor: `${FMS_STATUS_TEXT_COLORS[aircraft.fmsStatus]}`,
}}
>
<div
className="px-3 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-3"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -130,7 +130,7 @@ const AircraftPopupContent = ({
<House className="text-sm" />
</div>
<div
className="px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-4"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
}}
@@ -145,7 +145,7 @@ const AircraftPopupContent = ({
<ChevronsRightLeft className="text-sm" />
</div>
<div
className="flex justify-center items-center text-5xl font-bold px-6 cursor-pointer"
className="flex cursor-pointer items-center justify-center px-6 text-5xl font-bold"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -159,7 +159,7 @@ const AircraftPopupContent = ({
{aircraft.fmsStatus}
</div>
<div
className="cursor-pointer px-2 min-w-[130px]"
className="min-w-[130px] cursor-pointer px-2"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:
@@ -169,8 +169,8 @@ const AircraftPopupContent = ({
}}
onClick={() => handleTabChange("aircraft")}
>
<span className="text-white text-base font-medium truncate">
{aircraft.Station.bosCallsign.length > 16
<span className="truncate text-base font-medium text-white">
{aircraft.Station.bosCallsign.length > 15
? aircraft.Station.bosCallsignShort
: aircraft.Station.bosCallsign}
</span>
@@ -193,14 +193,14 @@ const AircraftPopupContent = ({
}}
onClick={() => handleTabChange("mission")}
>
<span className="text-white text-base font-medium">Einsatz</span>
<span className="text-base font-medium text-white">Einsatz</span>
<br />
<span className="text-white text-sm font-medium">
<span className="text-sm font-medium text-white">
{mission?.publicId || "kein Einsatz"}
</span>
</div>
<div
className="px-4 flex justify-center items-center cursor-pointer"
className="flex cursor-pointer items-center justify-center px-4"
style={{
backgroundColor: `${FMS_STATUS_COLORS[aircraft.fmsStatus]}`,
borderBottom:

View File

@@ -53,7 +53,7 @@ export const ContextMenu = () => {
if (!contextMenu || !dispatcherConnected) return null;
const einsatzBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen";
const missionBtnText = missionFormValues && isOpen ? "Position übernehmen" : "Einsatz erstellen";
const addOSMobjects = async (ignorePreviosSelected?: boolean) => {
const res = await fetch(
@@ -108,7 +108,7 @@ export const ContextMenu = () => {
{/* Top Button */}
<button
className="btn btn-circle bg-rescuetrack w-10 h-10 absolute left-1/2 top-0 pointer-events-auto opacity-80 tooltip tooltip-top tooltip-accent"
data-tip={einsatzBtnText}
data-tip={missionBtnText}
style={{ transform: "translateX(-50%)" }}
onClick={async () => {
const { parsed } = await getOsmAddress(contextMenu.lat, contextMenu.lng);

View File

@@ -12,6 +12,7 @@ import { MarkerCluster } from "_components/map/_components/MarkerCluster";
import { useEffect, useRef } from "react";
import { Map as TMap } from "leaflet";
import { DistanceLayer } from "_components/map/Measurement";
import { MapAdditionals } from "_components/map/MapAdditionals";
const Map = () => {
const ref = useRef<TMap | null>(null);
@@ -48,6 +49,7 @@ const Map = () => {
<MissionLayer />
<AircraftLayer />
<DistanceLayer />
<MapAdditionals />
</MapContainer>
);
};

View File

@@ -0,0 +1,76 @@
"use client";
import { usePannelStore } from "_store/pannelStore";
import { Marker } from "react-leaflet";
import L from "leaflet";
import { useQuery } from "@tanstack/react-query";
import { getMissionsAPI } from "_querys/missions";
import { HPGValidationRequired } from "_helpers/hpgValidationRequired";
import { getConnectedAircraftsAPI } from "_querys/aircrafts";
import { useMapStore } from "_store/mapStore";
export const MapAdditionals = () => {
const { isOpen, missionFormValues } = usePannelStore((state) => state);
const { data: missions = [] } = useQuery({
queryKey: ["missions"],
queryFn: () =>
getMissionsAPI({
OR: [{ state: "draft" }, { state: "running" }],
}),
refetchInterval: 10_000,
});
const mapStore = useMapStore((state) => state);
const { data: aircrafts } = useQuery({
queryKey: ["aircrafts"],
queryFn: () => getConnectedAircraftsAPI(),
refetchInterval: 10000,
});
const markersNeedingAttention = missions.filter(
(m) =>
HPGValidationRequired(m.missionStationIds, aircrafts, m.hpgMissionString) &&
m.hpgValidationState === "POSITION_AMANDED" &&
m.state === "draft" &&
m.hpgLocationLat &&
m.hpgLocationLng,
);
return (
<>
{missionFormValues?.addressLat && missionFormValues?.addressLng && isOpen && (
<Marker
position={[missionFormValues.addressLat, missionFormValues.addressLng]}
icon={L.icon({
iconUrl: "/icons/mapMarker.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
interactive={false}
/>
)}
{markersNeedingAttention.map((mission) => (
<Marker
key={mission.id}
position={[mission.hpgLocationLat!, mission.hpgLocationLng!]}
icon={L.icon({
iconUrl: "/icons/mapMarker.png",
iconSize: [40, 40],
iconAnchor: [20, 35],
})}
eventHandlers={{
click: () =>
mapStore.setOpenMissionMarker({
open: [
{
id: mission.id,
tab: "home",
},
],
close: [],
}),
}}
/>
))}
</>
);
};

View File

@@ -338,22 +338,15 @@ const MissionMarker = ({ mission }: { mission: Mission }) => {
return [
editingMissionId === mission.id && missionFormValues?.addressLat
? missionFormValues.addressLat
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLat
? mission.hpgLocationLat
: mission.addressLat,
: mission.addressLat,
editingMissionId === mission.id && missionFormValues?.addressLng
? missionFormValues.addressLng
: mission.hpgValidationState !== "POSITION_AMANDED" && mission.hpgLocationLng
? mission.hpgLocationLng
: mission.addressLng,
: mission.addressLng,
];
}, [
editingMissionId,
mission.addressLat,
mission.addressLng,
mission.hpgLocationLat,
mission.hpgLocationLng,
mission.hpgValidationState,
mission.id,
missionFormValues?.addressLat,
missionFormValues?.addressLng,

View File

@@ -64,9 +64,9 @@ const FMSStatusHistory = ({
return (
<div className="p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<p className="flex items-center gap-2 flex-1">
<PersonIcon className="w-5 h-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
<li className="mb-1 flex items-center gap-2">
<p className="flex flex-1 items-center gap-2">
<PersonIcon className="h-5 w-5" /> {aircraftUser.fullName} ({aircraftUser.publicId}){" "}
{(() => {
const badges = aircraftUser.badges
.filter((b) => b.startsWith("P") && b.length == 2)
@@ -96,12 +96,12 @@ const FMSStatusHistory = ({
</p>
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<ul className="space-y-2">
{log.map((entry, index) => (
<li key={index} className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
}}
@@ -145,8 +145,8 @@ const FMSStatusSelector = ({
});
return (
<div className="flex flex-col gap-2 mt-2 p-4 text-base-content">
<div className="flex gap-2 justify-center items-center h-full">
<div className="text-base-content mt-2 flex flex-col gap-2 p-4">
<div className="flex h-full items-center justify-center gap-2">
{Array.from({ length: 9 }, (_, i) => (i + 1).toString())
.filter((status) => status !== "5") // Exclude status 5
.map((status) => (
@@ -154,7 +154,7 @@ const FMSStatusSelector = ({
disabled={!dispatcherConnected}
key={status}
className={cn(
"flex justify-center items-center min-w-13 min-h-13 cursor-pointer text-4xl font-bold",
"min-w-13 min-h-13 flex cursor-pointer items-center justify-center text-4xl font-bold",
!dispatcherConnected && "cursor-not-allowed",
)}
style={{
@@ -187,13 +187,13 @@ const FMSStatusSelector = ({
</button>
))}
</div>
<div className="flex gap-1 p-2 justify-center items-center">
<div className="flex items-center justify-center gap-1 p-2">
{["E", "C", "F", "J", "L", "c", "d", "h", "o", "u"].map((status) => (
<button
disabled={!dispatcherConnected}
key={status}
className={cn(
"flex justify-center items-center min-w-10 min-h-10 cursor-pointer text-lg font-bold",
"flex min-h-10 min-w-10 cursor-pointer items-center justify-center text-lg font-bold",
!dispatcherConnected && "cursor-not-allowed",
)}
style={{
@@ -253,17 +253,17 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
}, [aircraft.posLng, aircraft.posLat, station.bosRadioArea]);
return (
<div className="p-4 text-base-content">
<div className="text-base-content p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<Component size={16} /> Aktuelle Rufgruppe: {livekitUser?.roomName || "Nicht verbunden"}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<RadioTower size={16} /> Leitstellenbereich: {lstName || station.bosRadioArea}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2 mb-2">
<div className="divider mb-0 mt-0" />
<div className="mb-2 mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<Clock size={16} /> {station.is24h ? "24h Betrieb" : "Tagbetrieb"}
</span>
@@ -277,8 +277,8 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
<TextSearch size={16} /> {station.aircraftRegistration}
</span>
</div>
<div className="divider mt-0 mb-0" />
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
<div className="divider mb-0 mt-0" />
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<CompassIcon size={16} /> HDG: {aircraft.posHeading}°
</span>
@@ -289,7 +289,7 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
<CircleGaugeIcon size={16} /> ALT: {aircraft.posAlt} ft
</span>
</div>
<div className="flex items-center text-sm font-semibold justify-between pr-2 mt-2">
<div className="mt-2 flex items-center justify-between pr-2 text-sm font-semibold">
<span className="flex items-center gap-2">
<Lollipop size={16} />{" "}
<span className={cn(aircraft.posH145active && "text-green-500")}>
@@ -303,22 +303,22 @@ const StationTab = ({ aircraft }: { aircraft: ConnectedAircraft & { Station: Sta
const MissionTab = ({ mission }: { mission: Mission }) => {
return (
<div className="p-4 text-base-content">
<div className="text-base-content p-4">
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<BellRing size={16} /> {mission.missionKeywordCategory}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<ListCollapse size={16} />
{mission.missionKeywordName}
</li>
<li className="flex items-center gap-2 mt-3">
<li className="mt-3 flex items-center gap-2">
<Hash size={16} />
__{new Date().toISOString().slice(0, 10).replace(/-/g, "")}
{mission.id}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
@@ -416,7 +416,7 @@ const SDSTab = ({
<div className="flex items-center gap-2">
{!isChatOpen ? (
<button
className="text-base-content text-base cursor-pointer"
className="text-base-content cursor-pointer text-base"
onClick={() => setIsChatOpen(true)}
>
<span className="flex items-center gap-2">
@@ -424,7 +424,7 @@ const SDSTab = ({
</span>
</button>
) : (
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
<input
autoFocus
type="text"
@@ -463,7 +463,7 @@ const SDSTab = ({
<div className="divider m-0" />
</div>
)}
<ul className="space-y-2 max-h-[300px] overflow-y-auto overflow-x-auto">
<ul className="max-h-[300px] space-y-2 overflow-x-auto overflow-y-auto">
{log.map((entry, index) => {
const sdsEntry = entry as MissionSdsLog;
return (
@@ -475,7 +475,7 @@ const SDSTab = ({
})}
</span>
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
@@ -488,7 +488,7 @@ const SDSTab = ({
);
})}
{!log.length && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Kein SDS-Verlauf verfügbar
</p>
)}

View File

@@ -93,8 +93,8 @@ const Einsatzdetails = ({
const { setMissionFormValues, setOpen, setEditingMission } = usePannelStore((state) => state);
const [ignoreHpg, setIgnoreHpg] = useState(false);
return (
<div className="p-4 text-base-content">
<div className="flex items-center justify-between mb-3">
<div className="text-base-content p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Flag /> Einsatzdetails
</h2>
@@ -126,7 +126,7 @@ const Einsatzdetails = ({
</button>
</div>
<div
className="tooltip tooltip-warning tooltip-left font-semibold z-[9999]"
className="tooltip tooltip-warning tooltip-left z-[9999] font-semibold"
data-tip="Einsatz abschließen"
>
<button
@@ -161,19 +161,19 @@ const Einsatzdetails = ({
)}
</div>
<ul className="text-base-content font-semibold">
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<BellRing size={16} /> {mission.missionKeywordCategory}
</li>
<li className="flex items-center gap-2 mb-1">
<li className="mb-1 flex items-center gap-2">
<ListCollapse size={16} />
{mission.missionKeywordName}
</li>
<li className="flex items-center gap-2 mt-3">
<li className="mt-3 flex items-center gap-2">
<Hash size={16} />
{mission.publicId}
</li>
</ul>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<MapPin size={16} /> {mission.addressLat} {mission.addressLng}
@@ -192,7 +192,7 @@ const Einsatzdetails = ({
</div>
{mission.type == "sekundär" && (
<>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="text-sm font-semibold">
<p className="flex items-center gap-2">
<Route size={16} /> {mission.addressMissionDestination}
@@ -202,11 +202,11 @@ const Einsatzdetails = ({
)}
{mission.state === "draft" && (
<div>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
{hpgNeedsAttention && mission.hpgValidationState !== "POSITION_AMANDED" && (
<div className="form-control mb-2 flex justify-between items-center">
<label className="flex items-center gap-2 cursor-pointer">
<div className="form-control mb-2 flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="checkbox checkbox-sm checkbox-primary"
@@ -214,7 +214,7 @@ const Einsatzdetails = ({
onChange={(e) => setIgnoreHpg(e.target.checked)}
/>
<span
className="label-text font-semibold leading-6 tooltip"
className="label-text tooltip font-semibold leading-6"
data-tip="Die HPG-Alarmierung wird trotzdem ausgeführt. Die Position des HPG-Einsatzes kann gravierend von der Einsatzposition abweichen"
>
HPG-Fehler ignorieren
@@ -235,7 +235,7 @@ const Einsatzdetails = ({
</div>
)}
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
{(!hpgNeedsAttention || ignoreHpg) &&
mission.hpgValidationState !== HpgValidationState.POSITION_AMANDED && (
<button
@@ -354,13 +354,13 @@ const Einsatzdetails = ({
const Patientdetails = ({ mission }: { mission: Mission }) => {
return (
<div className="p-4 text-base-content">
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
<div className="text-base-content p-4">
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
<User /> Patientendetails
</h2>
<p className="text-base-content font-semibold">{mission.missionPatientInfo}</p>
<div className="divider my-2" />
<h2 className="flex items-center gap-2 text-lg font-bold mb-3">
<h2 className="mb-3 flex items-center gap-2 text-lg font-bold">
<Cross /> Einsatzinformationen
</h2>
<p className="text-base-content font-semibold">{mission.missionAdditionalInfo}</p>
@@ -440,7 +440,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
const HPGVehicle = ({ state, name }: { state: HpgState; name: string }) => (
<li className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[hpgStateToFMSStatus(state)],
}}
@@ -457,8 +457,8 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
);
return (
<div className="p-4 text-base-content">
<div className="flex items-center w-full justify-between mb-2">
<div className="text-base-content p-4">
<div className="mb-2 flex w-full items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-bold">
<SmartphoneNfc /> Rettungsmittel
</h2>
@@ -480,9 +480,9 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</div>
)}
</div>
<ul className="space-y-2 h-[130px] overflow-y-auto overflow-x-auto flex-1">
<ul className="h-[130px] flex-1 space-y-2 overflow-x-auto overflow-y-auto">
{mission.missionStationIds.length === 0 && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Keine Rettungsmittel zugewiesen
</p>
)}
@@ -494,17 +494,17 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
return (
<li key={index} className="flex items-center gap-2">
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[connectedAircraft?.fmsStatus || "6"],
}}
>
{connectedAircraft?.fmsStatus || "6"}
</span>
<span className="text-base-content flex flex-col ">
<span className="text-base-content flex flex-col">
<span className="font-bold">{station.bosCallsign}</span>
{!connectedAircraft && (
<span className="text-gray-400 text-xs">Kein Benutzer verbunden</span>
<span className="text-xs text-gray-400">Kein Benutzer verbunden</span>
)}
</span>
</li>
@@ -522,7 +522,7 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
</ul>
{dispatcherConnected && (
<div>
<div className="divider mt-0 mb-0" />
<div className="divider mb-0 mt-0" />
<div className="flex items-center gap-2">
{/* TODO: make it a small multiselect */}
<StationsSelect
@@ -530,7 +530,6 @@ const Rettungsmittel = ({ mission }: { mission: Mission }) => {
className="min-w-[320px] flex-1"
isMulti={false}
onChange={(v) => {
console.log("Selected station:", v);
setSelectedStation({
selectedStationId: v?.selectedStationIds[0],
hpgAmbulanceState: mission.hpgAmbulanceState || HpgState.NOT_REQUESTED,
@@ -657,7 +656,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<div className="flex items-center gap-2">
{!isAddingNote ? (
<button
className="text-base-content text-base cursor-pointer"
className="text-base-content cursor-pointer text-base"
onClick={() => setIsAddingNote(true)}
>
<span className="flex items-center gap-2">
@@ -665,7 +664,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
</span>
</button>
) : (
<div className="flex items-center gap-2 w-full">
<div className="flex w-full items-center gap-2">
<input
type="text"
placeholder=""
@@ -697,7 +696,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
<div className="divider m-0" />
</div>
)}
<ul className="space-y-1 max-h-[300px] overflow-y-auto overflow-x-auto">
<ul className="max-h-[300px] space-y-1 overflow-x-auto overflow-y-auto">
{(mission.missionLog as unknown as MissionLog[])
.slice()
.reverse()
@@ -712,7 +711,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base"
className="text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[entry.data.newFMSstatus],
}}
@@ -732,7 +731,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base flex items-center gap-0.5"
className="flex items-center gap-0.5 text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
@@ -781,7 +780,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</span>
<span
className="font-bold text-base flex items-center gap-0.5"
className="flex items-center gap-0.5 text-base font-bold"
style={{
color: FMS_STATUS_TEXT_COLORS[6],
}}
@@ -830,7 +829,7 @@ const FMSStatusHistory = ({ mission }: { mission: Mission }) => {
})}
</ul>
{!mission.missionLog.length && (
<p className="text-gray-500 w-full text-center my-10 font-semibold">
<p className="my-10 w-full text-center font-semibold text-gray-500">
Keine Notizen verfügbar
</p>
)}

View File

@@ -91,16 +91,16 @@ export const SettingsBtn = () => {
modalRef.current?.showModal();
}}
>
<GearIcon className="w-5 h-5" />
<GearIcon className="h-5 w-5" />
</button>
<dialog ref={modalRef} className="modal">
<div className="modal-box">
<h3 className="flex items-center gap-2 text-lg font-bold mb-5">
<h3 className="mb-5 flex items-center gap-2 text-lg font-bold">
<SettingsIcon size={20} /> Einstellungen
</h3>
<div className="flex flex-col items-center justify-center">
<fieldset className="fieldset w-full mb-2">
<fieldset className="fieldset mb-2 w-full">
<label className="floating-label w-full text-base">
<span>Eingabegerät</span>
<select
@@ -122,7 +122,7 @@ export const SettingsBtn = () => {
</select>
</label>
</fieldset>
<p className="flex items-center gap-2 text-base mb-2 justify-start w-full">
<p className="mb-2 flex w-full items-center justify-start gap-2 text-base">
<Volume2 size={20} /> Eingabelautstärke
</p>
<div className="w-full">
@@ -139,7 +139,7 @@ export const SettingsBtn = () => {
value={settings.micVolume}
className="range range-xs range-accent w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -155,24 +155,23 @@ export const SettingsBtn = () => {
)}
<div className="divider w-full" />
</div>
<p className="flex items-center gap-2 text-base mb-2">
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Funk Lautstärke
</p>
<div className="w-full mb-2">
<div className="mb-2 w-full">
<input
type="range"
min={0}
max={1}
step={0.01}
onChange={(e) => {
console.log("Radio Volume", e.target.value);
const value = parseFloat(e.target.value);
setSettingsPartial({ radioVolume: value });
}}
value={settings.radioVolume}
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -181,7 +180,7 @@ export const SettingsBtn = () => {
</div>
</div>
<p className="flex items-center gap-2 text-base mb-2">
<p className="mb-2 flex items-center gap-2 text-base">
<Volume2 size={20} /> Melder Lautstärke
</p>
<div className="w-full">
@@ -200,7 +199,7 @@ export const SettingsBtn = () => {
value={settings.dmeVolume}
className="range range-xs range-primary w-full"
/>
<div className="flex justify-between px-2.5 mt-2 text-xs">
<div className="mt-2 flex justify-between px-2.5 text-xs">
<span>0%</span>
<span>25%</span>
<span>50%</span>
@@ -208,12 +207,12 @@ export const SettingsBtn = () => {
<span>100%</span>
</div>
</div>
<div className="flex justify-center w-full">
<div className="flex w-full justify-center">
<div className="divider w-full" />
</div>
<div className="w-full">
<label className="floating-label w-full">
<span className="text-lg flex items-center gap-2">
<span className="flex items-center gap-2 text-lg">
<Bell /> NTFY room
</span>
<input
@@ -226,7 +225,7 @@ export const SettingsBtn = () => {
<p className="label mt-2 w-full">
<Link
href="https://docs.virtualairrescue.com/docs/Leitstelle/App-Alarmierung#download"
href="https://docs.virtualairrescue.com/pilotenbereich/app-alarmierung.html#download"
target="_blank"
rel="noopener noreferrer"
className="link link-hover link-primary"
@@ -237,7 +236,7 @@ export const SettingsBtn = () => {
</p>
</div>
<div className="flex justify-between modal-action">
<div className="modal-action flex justify-between">
<button
className="btn btn-soft"
type="submit"

View File

@@ -4,33 +4,38 @@ import {
RemoteParticipant,
RemoteTrack,
RemoteTrackPublication,
Track,
} from "livekit-client";
const initialTrackTimeouts = new Map<string, NodeJS.Timeout>();
export const handleTrackSubscribed = (
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
) => {
const element = track.attach();
element.pause();
if (!track.isMuted) {
useAudioStore.getState().addSpeakingParticipant(participant);
initialTrackTimeouts.set(
participant.sid,
setTimeout(() => {
useAudioStore.getState().addSpeakingParticipant(participant);
}, 1000),
);
}
if (track.kind === Track.Kind.Video || track.kind === Track.Kind.Audio) {
// attach it to a new HTMLVideoElement or HTMLAudioElement
const element = track.attach();
setTimeout(() => {
element.play();
}, 1000);
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
element.volume = useAudioStore.getState().settings.radioVolume;
});
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
});
}
track.on("unmuted", () => {
useAudioStore.getState().addSpeakingParticipant(participant);
element.volume = useAudioStore.getState().settings.radioVolume;
});
track.on("muted", () => {
clearTimeout(initialTrackTimeouts.get(participant.sid));
initialTrackTimeouts.get(participant.sid);
useAudioStore.getState().removeSpeakingParticipant(participant);
});
};

View File

@@ -108,7 +108,6 @@ export const useAudioStore = create<TalkState>((set, get) => ({
) {
const { room, disconnect, connect } = get();
const role = room?.localParticipant.attributes.role;
console.log(role);
if (room?.name || role) {
disconnect();
connect(room?.name || "", role || "user");
@@ -170,10 +169,8 @@ export const useAudioStore = create<TalkState>((set, get) => ({
const inputStream = await navigator.mediaDevices.getUserMedia({
audio: {
deviceId: get().settings.micDeviceId ?? undefined,
noiseSuppression: true,
},
});
// Funk-Effekt anwenden
const radioStream = getRadioStream(inputStream, get().settings.micVolume);
if (!radioStream) throw new Error("Konnte Funkstream nicht erzeugen");
@@ -186,6 +183,7 @@ export const useAudioStore = create<TalkState>((set, get) => ({
source: Track.Source.Microphone,
});
await publishedTrack.mute();
set({ localRadioTrack: publishedTrack });
set({ state: "connected", room, message: null });

View File

@@ -116,14 +116,12 @@ dispatchSocket.on(
"chat-message",
({ userId, message }: { userId: string; message: ChatMessage }) => {
const store = useLeftMenuStore.getState();
console.log("chat-message", userId, message);
// Update the chat store with the new message
store.addMessage(userId, message);
},
);
pilotSocket.on("chat-message", ({ userId, message }: { userId: string; message: ChatMessage }) => {
const store = useLeftMenuStore.getState();
console.log("chat-message", userId, message);
// Update the chat store with the new message
store.addMessage(userId, message);
});

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"type": "module",
"private": true,
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"scripts": {
"dev": "next dev --turbopack -p 3001",
"build": "next build",
@@ -14,9 +14,9 @@
"dependencies": {
"@eslint/eslintrc": "^3.3.1",
"@hookform/resolvers": "^5.1.1",
"@livekit/components-react": "^2.9.12",
"@livekit/components-react": "^2.9.14",
"@livekit/components-styles": "^1.1.6",
"@livekit/track-processors": "^0.5.7",
"@livekit/track-processors": "^0.5.8",
"@next-auth/prisma-adapter": "^1.0.7",
"@radix-ui/react-icons": "^1.3.2",
"@repo/db": "workspace:*",
@@ -24,37 +24,37 @@
"@repo/shared-components": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query": "^5.83.0",
"@turf/turf": "^7.2.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.19",
"@types/leaflet": "^1.9.20",
"@types/node": "^22.15.34",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"axios": "^1.10.0",
"clsx": "^2.1.1",
"daisyui": "^5.0.43",
"daisyui": "^5.0.46",
"date-fns": "^4.1.0",
"eslint-config-next": "^15.3.4",
"eslint-config-next": "^15.4.2",
"geojson": "^0.5.0",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"leaflet": "^1.9.4",
"leaflet.polylinemeasure": "^3.0.0",
"livekit-client": "^2.14.0",
"livekit-client": "^2.15.3",
"livekit-server-sdk": "^2.13.1",
"lucide-react": "^0.511.0",
"next": "^15.3.4",
"lucide-react": "^0.525.0",
"next": "^15.4.2",
"next-auth": "^4.24.11",
"npm": "^11.4.2",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.59.0",
"react-hook-form": "^7.60.0",
"react-hot-toast": "^2.5.2",
"react-leaflet": "^5.0.0",
"react-select": "^5.10.1",
"react-select": "^5.10.2",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -14,14 +14,6 @@ const initTransporter = () => {
if (!process.env.MAIL_USER) return console.error("MAIL_USER is not defined");
if (!process.env.MAIL_PASSWORD) return console.error("MAIL_PASSWORD is not defined");
console.log("Initializing mail transporter...", {
host: process.env.MAIL_SERVER,
port: process.env.MAIL_PORT,
user: process.env.MAIL_USER,
password: process.env.MAIL_PASSWORD,
secure: process.env.MAIL_SECURE === "true",
});
transporter = nodemailer.createTransport({
host: process.env.MAIL_SERVER,
port: parseInt(process.env.MAIL_PORT),

View File

@@ -8,30 +8,30 @@
"start": "tsx index.ts --transpile-only",
"build": "tsc"
},
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"devDependencies": {
"concurrently": "^9.1.2",
"concurrently": "^9.2.0",
"typescript": "latest"
},
"dependencies": {
"@react-email/components": "^0.0.41",
"@react-email/components": "^0.3.2",
"@repo/shared-components": "workspace:*",
"@repo/db": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/cors": "^2.8.18",
"@types/express": "^5.0.2",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.15.29",
"@types/nodemailer": "^6.4.17",
"@types/react": "^19.1.6",
"axios": "^1.9.0",
"@types/react": "^19.1.8",
"axios": "^1.10.0",
"cors": "^2.8.5",
"cron": "^4.3.1",
"dotenv": "^16.5.0",
"cron": "^4.3.2",
"dotenv": "^17.2.0",
"express": "^5.1.0",
"nodemailer": "^7.0.3",
"nodemailer": "^7.0.5",
"nodemon": "^3.1.10",
"react": "^19.1.0",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.4"
"tsx": "^4.20.3"
}
}

View File

@@ -34,8 +34,8 @@ export const AppointmentModal = ({
const participantTableRef = useRef<PaginatedTableRef>(null);
return (
<dialog ref={ref} className="modal ">
<div className="modal-box min-w-[900px] min-h-[500px]">
<dialog ref={ref} className="modal">
<div className="modal-box min-h-[500px] min-w-[900px]">
<form method="dialog">
{/* if there is a button in form, it will close the modal */}
<button
@@ -55,8 +55,8 @@ export const AppointmentModal = ({
})}
className="flex flex-col"
>
<div className="flex justify-between mr-7">
<h3 className="font-bold text-lg">Termin {appointmentForm.watch("id")}</h3>
<div className="mr-7 flex justify-between">
<h3 className="text-lg font-bold">Termin {appointmentForm.watch("id")}</h3>
<DateInput
value={new Date(appointmentForm.watch("appointmentDate") || Date.now())}
onChange={(date) => appointmentForm.setValue("appointmentDate", date)}
@@ -121,11 +121,6 @@ export const AppointmentModal = ({
attended: true,
appointmentCancelled: false,
});
console.log(
"Participant attended",
event.finisherMoodleCourseId,
!event.finisherMoodleCourseId?.length,
);
if (!event.finisherMoodleCourseId?.length) {
toast(
"Teilnehmer hat das event abgeschlossen, workflow ausgeführt",

View File

@@ -11,7 +11,7 @@ import {
import { Bot, Calendar, FileText, UserIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";
import { useRef, useState } from "react";
import { useRef } from "react";
import "react-datepicker/dist/react-datepicker.css";
import { useForm } from "react-hook-form";
import { PaginatedTable, PaginatedTableRef } from "../../../../_components/PaginatedTable";
@@ -24,6 +24,7 @@ import { deleteEvent, upsertEvent } from "../action";
import { AppointmentModal } from "./AppointmentModal";
import { ParticipantModal } from "./ParticipantModal";
import { ColumnDef } from "@tanstack/react-table";
import toast from "react-hot-toast";
export const Form = ({ event }: { event?: Event }) => {
const { data: session } = useSession();
@@ -66,6 +67,7 @@ export const Form = ({ event }: { event?: Event }) => {
<form
onSubmit={form.handleSubmit(async (values) => {
await upsertEvent(values, event?.id);
toast.success("Event erfolgreich gespeichert");
if (!event) redirect(`/admin/event`);
})}
className="grid grid-cols-6 gap-3"
@@ -75,17 +77,6 @@ export const Form = ({ event }: { event?: Event }) => {
<h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines
</h2>
<Input form={form} label="Name" name="name" className="input-sm" />
<MarkdownEditor form={form} name="description" />
<Input
form={form}
label="Maximale Teilnehmer (Nur für live Events)"
className="input-sm"
{...form.register("maxParticipants", {
valueAsNumber: true,
})}
/>
<Switch form={form} name="hidden" label="Versteckt" />
<Select
form={form}
name="type"
@@ -95,6 +86,9 @@ export const Form = ({ event }: { event?: Event }) => {
value: value,
}))}
/>
<Input form={form} label="Name" name="name" className="input-sm" />
<MarkdownEditor form={form} name="description" />
<MarkdownEditor form={form} name="descriptionShort" />
</div>
</div>
<div className="card bg-base-200 shadow-xl col-span-3 max-xl:col-span-6">
@@ -144,7 +138,18 @@ export const Form = ({ event }: { event?: Event }) => {
label="Discord Rolle für eingeschriebene Teilnehmer"
className="input-sm"
/>
<Input
form={form}
label="Maximale Teilnehmer (Nur für live Events)"
className="input-sm"
{...form.register("maxParticipants", {
valueAsNumber: true,
})}
/>
<Switch form={form} name="hasPresenceEvents" label="Hat Live Event" />
<div className="divider w-full" />
<Switch form={form} name="hidden" label="Event verstecken" />
</div>
</div>
{form.watch("hasPresenceEvents") ? (

View File

@@ -11,7 +11,7 @@ const page = () => {
<PaginatedTable
stickyHeaders
prismaModel="heliport"
searchFields={["siteName", "info", "hospital"]}
searchFields={["siteName", "info", "hospital", "designator"]}
columns={
[
{
@@ -45,11 +45,11 @@ const page = () => {
}
leftOfSearch={
<span className="flex items-center gap-2">
<DatabaseBackupIcon className="w-5 h-5" /> Heliports
<DatabaseBackupIcon className="h-5 w-5" /> Heliports
</span>
}
rightOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2 justify-between">
<p className="flex items-center justify-between gap-2 text-left text-2xl font-semibold">
<Link href={"/admin/heliport/new"}>
<button className="btn btn-sm btn-outline btn-primary">Erstellen</button>
</Link>

View File

@@ -2,20 +2,26 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { StationOptionalDefaultsSchema } from "@repo/db/zod";
import { useForm } from "react-hook-form";
import { BosUse, Country, Station } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon } from "lucide-react";
import { BosUse, ConnectedAircraft, Country, Station, User } from "@repo/db";
import { FileText, LocateIcon, PlaneIcon, UserIcon } from "lucide-react";
import { Input } from "../../../../_components/ui/Input";
import { useState } from "react";
import { deleteStation, upsertStation } from "../action";
import { Button } from "../../../../_components/ui/Button";
import { redirect } from "next/navigation";
import toast from "react-hot-toast";
import { PaginatedTable, PaginatedTableRef } from "_components/PaginatedTable";
import { ColumnDef } from "@tanstack/react-table";
import Link from "next/link";
import { deletePilotHistory } from "(app)/admin/user/action";
import { useRef } from "react";
import { cn } from "@repo/shared-components";
export const StationForm = ({ station }: { station?: Station }) => {
const form = useForm({
resolver: zodResolver(StationOptionalDefaultsSchema),
defaultValues: station,
});
const dispoTableRef = useRef<PaginatedTableRef>(null);
// const [deleteLoading, setDeleteLoading] = useState(false);
return (
<>
@@ -27,10 +33,10 @@ export const StationForm = ({ station }: { station?: Station }) => {
})}
className="grid grid-cols-6 gap-3"
>
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card bg-base-200 col-span-2 shadow-xl max-xl:col-span-6">
<div className="card-body">
<h2 className="card-title">
<FileText className="w-5 h-5" /> Allgemeines
<FileText className="h-5 w-5" /> Allgemeines
</h2>
<Input form={form} label="BOS Rufname" name="bosCallsign" className="input-sm" />
<Input
@@ -55,7 +61,7 @@ export const StationForm = ({ station }: { station?: Station }) => {
/>
<label className="form-control w-full">
<span className="label-text text-lg flex items-center gap-2">BOS Nutzung</span>
<span className="label-text flex items-center gap-2 text-lg">BOS Nutzung</span>
<select
className="input-sm select select-bordered select-sm"
{...form.register("bosUse")}
@@ -69,13 +75,13 @@ export const StationForm = ({ station }: { station?: Station }) => {
</label>
</div>
</div>
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card bg-base-200 col-span-2 shadow-xl max-xl:col-span-6">
<div className="card-body">
<h2 className="card-title">
<LocateIcon className="w-5 h-5" /> Standort + Ausrüstung
<LocateIcon className="h-5 w-5" /> Standort + Ausrüstung
</h2>
<label className="form-control w-full">
<span className="label-text text-lg flex items-center gap-2">Land</span>
<span className="label-text flex items-center gap-2 text-lg">Land</span>
<select
className="input-sm select select-bordered select-sm"
{...form.register("country", {})}
@@ -94,21 +100,21 @@ export const StationForm = ({ station }: { station?: Station }) => {
name="locationStateShort"
className="input-sm"
/>
<span className="label-text text-lg flex items-center gap-2">Ausgerüstet mit:</span>
<span className="label-text flex items-center gap-2 text-lg">Ausgerüstet mit:</span>
<div className="form-control space-y-2">
<label className="label cursor-pointer flex">
<label className="label flex cursor-pointer">
<span className="flex-1 text-left">Winde</span>
<input type="checkbox" className="toggle" {...form.register("hasWinch")} />
</label>
<label className="label cursor-pointer flex">
<label className="label flex cursor-pointer">
<span className="flex-1 text-left">Nachtsicht-Gerät</span>
<input type="checkbox" className="toggle" {...form.register("hasNvg")} />
</label>
<label className="label cursor-pointer flex">
<label className="label flex cursor-pointer">
<span className="flex-1 text-left">24-Stunden Einsatzfähig</span>
<input type="checkbox" className="toggle" {...form.register("is24h")} />
</label>
<label className="label cursor-pointer flex">
<label className="label flex cursor-pointer">
<span className="flex-1 text-left">Bergetau</span>
<input type="checkbox" className="toggle" {...form.register("hasRope")} />
</label>
@@ -132,16 +138,16 @@ export const StationForm = ({ station }: { station?: Station }) => {
type="number"
step="any"
/>
<label className="label cursor-pointer flex">
<span className="text-lg flex-1 text-left">Reichweiten ausblenden</span>
<label className="label flex cursor-pointer">
<span className="flex-1 text-left text-lg">Reichweiten ausblenden</span>
<input type="checkbox" className="toggle" {...form.register("hideRangeRings")} />
</label>
</div>
</div>
<div className="card bg-base-200 shadow-xl col-span-2 max-xl:col-span-6">
<div className="card bg-base-200 col-span-2 shadow-xl max-xl:col-span-6">
<div className="card-body">
<h2 className="card-title">
<PlaneIcon className="w-5 h-5" /> Hubschrauber
<PlaneIcon className="h-5 w-5" /> Hubschrauber
</h2>
<Input form={form} label="Hubschrauber Typ" name="aircraft" className="input-sm" />
<Input
@@ -160,8 +166,8 @@ export const StationForm = ({ station }: { station?: Station }) => {
/>
</div>
</div>
<div className="card bg-base-200 shadow-xl col-span-6">
<div className="card-body ">
<div className="card bg-base-200 col-span-6 shadow-xl">
<div className="card-body">
<div className="flex w-full gap-4">
<Button
isLoading={form.formState.isSubmitting}
@@ -184,6 +190,93 @@ export const StationForm = ({ station }: { station?: Station }) => {
</div>
</div>
</div>
<div className="card bg-base-200 col-span-6 shadow-xl">
<PaginatedTable
leftOfSearch={
<div className="flex items-center gap-2 text-lg font-bold">
<UserIcon className="h-5 w-5" />
Verbundene Piloten
</div>
}
filter={{
stationId: station?.id,
}}
searchFields={["User.firstname", "User.lastname", "User.publicId"]}
prismaModel={"connectedAircraft"}
include={{ Station: true, User: true }}
columns={
[
{
accessorKey: "User.firstname",
header: "Nutzer",
cell: ({ row }) => {
return (
<Link
className="link link-hover"
href={`/admin/user/${row.original.User.id}`}
>
{row.original.User.firstname} {row.original.User.lastname} (
{row.original.User.publicId})
</Link>
);
},
},
{
accessorKey: "loginTime",
header: "Login",
cell: ({ row }) => {
return new Date(row.getValue("loginTime")).toLocaleString("de-DE");
},
},
{
accessorKey: "logoutTime",
header: "Logout",
cell: ({ row }) => {
return new Date(row.getValue("logoutTime")).toLocaleString("de-DE");
},
},
{
header: "Time Online",
cell: ({ row }) => {
if (!row.original.logoutTime) {
return <span className="text-success">Online</span>;
}
const loginTime = new Date(row.original.loginTime).getTime();
const logoutTime = new Date(row.original.logoutTime).getTime();
const timeOnline = logoutTime - loginTime;
const hours = Math.floor(timeOnline / 1000 / 60 / 60);
const minutes = Math.floor((timeOnline / 1000 / 60) % 60);
return (
<span className={cn(hours > 2 && "text-error")}>
{hours}h {minutes}min
</span>
);
},
},
{
header: "Aktionen",
cell: ({ row }) => {
return (
<div>
<button
className="btn btn-sm btn-error"
onClick={async () => {
await deletePilotHistory(row.original.id);
dispoTableRef.current?.refresh();
}}
>
löschen
</button>
</div>
);
},
},
] as ColumnDef<ConnectedAircraft & { Station: Station; User: User }>[]
}
/>
</div>
</form>
</>
);

View File

@@ -10,7 +10,7 @@ import {
Station,
User,
} from "@repo/db";
import { useRef, useState } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import {
deleteDispoHistory,
@@ -84,11 +84,11 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
})}
>
<h2 className="card-title">
<MixerHorizontalIcon className="w-5 h-5" /> User bearbeiten
<MixerHorizontalIcon className="h-5 w-5" /> User bearbeiten
</h2>
<div className="text-left">
<label className="floating-label w-full mb-5 mt-5">
<span className="text-lg flex items-center gap-2">
<label className="floating-label mb-5 mt-5 w-full">
<span className="flex items-center gap-2 text-lg">
<PersonIcon /> Vorname
</span>
<input
@@ -102,8 +102,8 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
{form.formState.errors.firstname && (
<p className="text-error">{form.formState.errors.firstname.message}</p>
)}
<label className="floating-label w-full mb-5">
<span className="text-lg flex items-center gap-2">
<label className="floating-label mb-5 w-full">
<span className="flex items-center gap-2 text-lg">
<PersonIcon /> Nachname
</span>
<input
@@ -120,13 +120,13 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
{session.data?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<>
<label className="floating-label w-full">
<span className="text-lg flex items-center gap-2">
<span className="flex items-center gap-2 text-lg">
<EnvelopeClosedIcon /> E-Mail
</span>
<input
{...form.register("email")}
type="text"
className="input input-bordered w-full mb-2"
className="input input-bordered mb-2 w-full"
defaultValue={user?.email}
placeholder="E-Mail"
/>
@@ -166,7 +166,7 @@ export const ProfileForm: React.FC<ProfileFormProps> = ({ user }: ProfileFormPro
{session.data?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div className="mt-2 space-y-1">
<p className="text-gray-400">Berechtigung Schnellauswahl</p>
<div className="flex gap-2 justify-evenly">
<div className="flex justify-evenly gap-2">
<button
type="button"
className="btn btn-sm btn-outline"
@@ -251,7 +251,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
<div className="card-body flex-row flex-wrap">
<div className="flex-1">
<h2 className="card-title">
<MixerHorizontalIcon className="w-5 h-5" /> Dispo-Verbindungs Historie
<MixerHorizontalIcon className="h-5 w-5" /> Dispo-Verbindungs Historie
</h2>
<PaginatedTable
ref={dispoTableRef}
@@ -313,7 +313,7 @@ export const ConnectionHistory: React.FC<{ user: User }> = ({ user }: { user: Us
</div>
<div className="flex-1">
<h2 className="card-title">
<PlaneIcon className="w-5 h-5" /> Pilot-Verbindungs Historie
<PlaneIcon className="h-5 w-5" /> Pilot-Verbindungs Historie
</h2>
<PaginatedTable
ref={dispoTableRef}
@@ -396,7 +396,7 @@ export const UserPenalties = ({ user }: { user: User }) => {
<div className="card-body">
<h2 className="card-title flex justify-between">
<span className="flex items-center gap-2">
<ExclamationTriangleIcon className="w-5 h-5" /> Audit-log
<ExclamationTriangleIcon className="h-5 w-5" /> Audit-log
</span>
<div className="flex gap-2">
<PenaltyDropdown
@@ -478,7 +478,7 @@ export const UserReports = ({ user }: { user: User }) => {
return (
<div className="card-body">
<h2 className="card-title">
<ExclamationTriangleIcon className="w-5 h-5" /> Nutzer Reports
<ExclamationTriangleIcon className="h-5 w-5" /> Nutzer Reports
</h2>
<PaginatedTable
prismaModel="report"
@@ -528,10 +528,10 @@ export const AdminForm = ({
return (
<div className="card-body">
<h2 className="card-title">
<LightningBoltIcon className="w-5 h-5" /> Administration
<LightningBoltIcon className="h-5 w-5" /> Administration
</h2>
<div className="text-left">
<div className="card-actions pt-6 flex flex-wrap gap-2">
<div className="card-actions flex flex-wrap gap-2 pt-6">
<Button
onClick={async () => {
const { password } = await resetPassword(user.id);
@@ -547,13 +547,13 @@ export const AdminForm = ({
},
);
}}
className="btn-sm flex-1 min-w-[250px] btn-outline btn-success"
className="btn-sm btn-outline btn-success min-w-[250px] flex-1"
>
<LockOpen1Icon /> Passwort zurücksetzen
</Button>
{session?.user.permissions.includes("ADMIN_USER_ADVANCED") && (
<div
className="tooltip flex-1 min-w-[250px] tooltip-warning"
className="tooltip tooltip-warning min-w-[250px] flex-1"
data-tip="Dies löscht den Nutzer sofort, außerdem alle Reports, Verbindungs-Verlauf und Chat-Nachrichten"
>
<Button
@@ -581,14 +581,14 @@ export const AdminForm = ({
router.refresh();
}}
role="submit"
className="btn-sm flex-1 min-w-[250px] btn-outline btn-warning"
className="btn-sm btn-outline btn-warning min-w-[250px] flex-1"
>
<HobbyKnifeIcon /> Account entsperren
</Button>
)}
{discordAccount && (
<div
className="tooltip flex-1 min-w-[250px]"
className="tooltip min-w-[250px] flex-1"
data-tip={`Name: ${discordAccount.username}`}
>
<Button
@@ -604,7 +604,7 @@ export const AdminForm = ({
},
});
}}
className="btn-sm w-full btn-outline btn-info"
className="btn-sm btn-outline btn-info w-full"
>
<DiscordLogoIcon /> Name und Berechtigungen setzen
</Button>
@@ -613,12 +613,12 @@ export const AdminForm = ({
</div>
</div>
<h2 className="card-title">
<ChartBarBigIcon className="w-5 h-5" /> Aktivität
<ChartBarBigIcon className="h-5 w-5" /> Aktivität
</h2>
<div className="stats flex">
<div className="stat">
<div className="stat-figure text-primary">
<LightningBoltIcon className="w-8 h-8" />
<LightningBoltIcon className="h-8 w-8" />
</div>
<div className="stat-value text-primary">
{dispoTime.hours}h {dispoTime.minutes}min
@@ -630,7 +630,7 @@ export const AdminForm = ({
</div>
<div className="stat">
<div className="stat-figure text-primary">
<PlaneIcon className="w-8 h-8" />
<PlaneIcon className="h-8 w-8" />
</div>
<div className="stat-value text-primary">
{pilotTime.hours}h {pilotTime.minutes}min
@@ -642,12 +642,12 @@ export const AdminForm = ({
</div>
</div>
<h2 className="card-title">
<ExclamationTriangleIcon className="w-5 h-5" /> Reports
<ExclamationTriangleIcon className="h-5 w-5" /> Reports
</h2>
<div className="stats flex">
<div className="stat">
<div className="stat-figure text-primary">
<ExclamationTriangleIcon className="w-8 h-8" />
<ExclamationTriangleIcon className="h-8 w-8" />
</div>
<div className={cn("stat-value text-primary", reports.open && "text-warning")}>
{reports.open}
@@ -656,7 +656,7 @@ export const AdminForm = ({
</div>
<div className="stat">
<div className="stat-figure text-primary">
<Timer className="w-8 h-8" />
<Timer className="h-8 w-8" />
</div>
<div className="stat-value text-primary">{reports.total60Days}</div>
<div className="stat-title">in den letzten 60 Tagen</div>

View File

@@ -34,6 +34,23 @@ const AdminUserPage = () => {
header: "Nachname",
accessorKey: "lastname",
},
{
header: "Berechtigungen",
cell(props) {
if (props.row.original.permissions.length === 0) {
return <span className="text-gray-700">Keine</span>;
} else if (props.row.original.permissions.includes("ADMIN_USER_ADVANCED")) {
return <span className="text-primary">Admin</span>;
}
return (
<span className="text-secondary">
{props.row.original.permissions
.filter((p) => p === "PILOT" || p === "DISPO")
.join(", ")}
</span>
);
},
},
...(session?.user.permissions.includes("ADMIN_USER_ADVANCED")
? [
{
@@ -55,8 +72,8 @@ const AdminUserPage = () => {
] as ColumnDef<User>[]
} // Define the columns for the user table
leftOfSearch={
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<User2 className="w-5 h-5" /> Benutzer
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<User2 className="h-5 w-5" /> Benutzer
</p>
}
/>

View File

@@ -38,8 +38,7 @@ export const EventCard = ({
<div className="col-span-4">
<div className="text-left text-balance">
<MDEditor.Markdown
source={event.description}
className="whitespace-pre-wrap"
source={event.descriptionShort}
style={{
backgroundColor: "transparent",
}}

View File

@@ -157,7 +157,6 @@ const ModalBtn = ({
<div className="text-left text-balance">
<MDEditor.Markdown
source={event.description}
className="whitespace-pre-wrap"
style={{
backgroundColor: "transparent",
}}

View File

@@ -13,10 +13,8 @@ export default function Page() {
const verifyCode = useCallback(
async (code: string) => {
console.log("Verifying code:", code);
if (!code) return;
const res = await checkEmailCode(code);
console.log("Verification response:", res);
if (res.error) {
console.log("Verification error:", res.error);
toast.error(res.error);
@@ -34,12 +32,12 @@ export default function Page() {
}, [paramsCode, verifyCode]);
return (
<div className="card bg-base-200 shadow-xl mb-4 ">
<div className="card bg-base-200 mb-4 shadow-xl">
<div className="card-body">
<p className="text-2xl font-semibold text-left flex items-center gap-2">
<Check className="w-5 h-5" /> E-Mail Bestätigung
<p className="flex items-center gap-2 text-left text-2xl font-semibold">
<Check className="h-5 w-5" /> E-Mail Bestätigung
</p>
<div className="flex justify-center gap-3 w-full">
<div className="flex w-full justify-center gap-3">
<input
className="input flex-1"
placeholder="Bestätigungscode"

View File

@@ -21,13 +21,8 @@ export const options: AuthOptions = {
where: { email: credentials.email },
});
const v1User = (oldUser as OldUser[]).find((u) => u.email === credentials.email);
console.log("V1 User", v1User?.publicId);
if (!user && v1User) {
if (bcrypt.compareSync(credentials.password, v1User.password)) {
console.log(
"v1 User Passwords match:",
bcrypt.compareSync(credentials.password, v1User.password),
);
const newUser = await createNewUserFromOld(v1User);
await sendVerificationLink(newUser.id);
return newUser;

View File

@@ -49,7 +49,6 @@ export const getMoodleUserById = async (id: string) => {
},
},
);
console.log("Moodle User", user);
const u = user[0];
return (
(u as {

View File

@@ -1,14 +1,11 @@
export const generateUUID = (length: number) => {
// Base62-Version (a-z, A-Z, 0-9)
const base62 =
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const base62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
let string = "";
for (let i = 0; i < length; i++) {
string += base62.charAt(Math.floor(Math.random() * base62.length));
}
console.log(string);
return string;
};

View File

@@ -2,7 +2,7 @@
"name": "hub",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.11.0",
"packageManager": "pnpm@10.13.1",
"scripts": {
"dev": "next dev --turbopack -p 3000",
"build": "next build",
@@ -19,45 +19,45 @@
"@repo/shared-components": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-table": "^8.21.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.15.34",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@uiw/react-md-editor": "^4.0.7",
"@uiw/react-md-editor": "^4.0.8",
"axios": "^1.10.0",
"bcryptjs": "^3.0.2",
"clsx": "^2.1.1",
"daisyui": "^5.0.43",
"daisyui": "^5.0.46",
"date-fns": "^4.1.0",
"eslint-config-next": "^15.3.4",
"eslint-config-next": "^15.4.2",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"lucide-react": "^0.511.0",
"next": "^15.3.4",
"lucide-react": "^0.525.0",
"next": "^15.4.2",
"next-auth": "^4.24.11",
"next-remove-imports": "^1.0.12",
"npm": "^11.4.2",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-datepicker": "^8.4.0",
"react-day-picker": "^9.7.0",
"react-day-picker": "^9.8.0",
"react-dom": "^19.1.0",
"react-error-boundary": "^6.0.0",
"react-hook-form": "^7.59.0",
"react-hook-form": "^7.60.0",
"react-hot-toast": "^2.5.2",
"react-select": "^5.10.1",
"react-select": "^5.10.2",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
"zod": "^3.25.67",
"zustand": "^5.0.6"
},
"devDependencies": {
"@eslint/js": "^9.30.0",
"eslint": "^9.30.0",
"@eslint/js": "^9.31.0",
"eslint": "^9.31.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.33.1"
"typescript-eslint": "^8.37.0"
}
}

View File

@@ -12,20 +12,21 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"devDependencies": {
"prettier": "^3.5.3",
"turbo": "^2.5.4",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"turbo": "^2.5.5",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=18",
"pnpm": ">=10"
},
"packageManager": "pnpm@10.12.1",
"packageManager": "pnpm@10.13.1",
"workspaces": [
"apps/*",
"packages/*"
],
"dependencies": {
"eslint": "^9.30.1"
"eslint": "^9.31.0"
}
}

View File

@@ -20,11 +20,11 @@
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.8.2",
"@prisma/client": "^6.12.0",
"zod": "^3.25.46",
"zod-prisma-types": "^3.2.4"
},
"devDependencies": {
"prisma": "^6.8.2"
"prisma": "^6.12.0"
}
}

View File

@@ -37,6 +37,7 @@ model Participant {
model Event {
id Int @id @default(autoincrement())
name String
descriptionShort String @default("")
description String
type EVENT_TYPE @default(EVENT)
discordRoleId String? @default("")

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "descriptionShort" TEXT NOT NULL DEFAULT '';

View File

@@ -8,19 +8,19 @@
"./react-internal": "./react-internal.js"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@next/eslint-plugin-next": "^15.3.3",
"eslint": "^9.15.0",
"eslint-config-prettier": "^9.1.0",
"@eslint/js": "^9.31.0",
"@next/eslint-plugin-next": "^15.4.2",
"eslint": "^9.31.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-only-warn": "^1.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-turbo": "^2.3.0",
"globals": "^15.12.0",
"eslint-plugin-turbo": "^2.5.5",
"globals": "^16.3.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.15.0"
"typescript-eslint": "^8.37.0"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.36.0"
"@typescript-eslint/eslint-plugin": "^8.37.0"
}
}

View File

@@ -1,3 +1,4 @@
"use client";
import { set, isBefore, addDays } from "date-fns";
export function getNextDateWithTime(targetHour: number, targetMinute: number): Date {

View File

@@ -12,11 +12,11 @@
"@types/node": "^22.15.29",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"tailwind-merge": "^3.3.0"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.5",
"@types/react-dom": "^19.1.6",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}

3622
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff