hellth-hub/apps/admin/app/(authed)/maerkte/page.tsx

336 lines
16 KiB
TypeScript
Raw Normal View History

"use client";
import { ArrowTopRightOnSquareIcon, MapPinIcon, PlusIcon, StarIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { FormField, inputClassName } from "@/app/_components/form-field";
import { uid } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type DepartmentDraft = {
id: string;
name: string;
sortOrder: number;
};
type StoreDraft = {
id?: string;
name: string;
address?: string | null;
placeId?: string | null;
latitude?: number | null;
longitude?: number | null;
favorite: boolean;
departments: DepartmentDraft[];
};
function defaultDepartments(storeId: string): DepartmentDraft[] {
return [
{ id: `${storeId}-produce`, name: "Obst & Gemüse", sortOrder: 1 },
{ id: `${storeId}-bakery`, name: "Brot & Backwaren", sortOrder: 2 },
{ id: `${storeId}-dairy`, name: "Milchprodukte", sortOrder: 3 },
{ id: `${storeId}-protein`, name: "Fleisch/Fisch/Protein", sortOrder: 4 },
{ id: `${storeId}-drinks`, name: "Getränke", sortOrder: 5 },
{ id: `${storeId}-other`, name: "Sonstiges", sortOrder: 99 },
];
}
function mapsSearchUrl(name: string, address?: string | null) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([name, address].filter(Boolean).join(" "))}`;
}
export default function MaerktePage() {
const utils = trpc.useUtils();
const [storeName, setStoreName] = useState("");
const [storeAddress, setStoreAddress] = useState("");
const [storeLat, setStoreLat] = useState("");
const [storeLng, setStoreLng] = useState("");
const [departmentNames, setDepartmentNames] = useState<Record<string, string>>({});
const [changeNotes, setChangeNotes] = useState<Record<string, string>>({});
const [status, setStatus] = useState<string | null>(null);
const storesQuery = trpc.health.listStores.useQuery();
const requestsQuery = trpc.health.myStoreChangeRequests.useQuery();
const saveStoreMutation = trpc.health.saveStore.useMutation({
onSuccess: async () => {
await utils.health.listStores.invalidate();
setStatus("Markt gespeichert.");
},
});
const submitStoreChangeRequest = trpc.health.submitStoreChangeRequest.useMutation({
onSuccess: async () => {
await utils.health.myStoreChangeRequests.invalidate();
setStatus("Änderungsantrag eingereicht.");
},
});
const setStoreFavoriteMutation = trpc.health.setStoreFavorite.useMutation({
onSuccess: () => utils.health.listStores.invalidate(),
});
const deleteStoreMutation = trpc.health.deleteStore.useMutation({
onSuccess: () => utils.health.listStores.invalidate(),
});
const stores = useMemo(() => storesQuery.data ?? [], [storesQuery.data]);
function saveDraft(draft: StoreDraft) {
saveStoreMutation.mutate({
id: draft.id,
name: draft.name,
address: draft.address || null,
placeId: draft.placeId || null,
latitude: draft.latitude ?? null,
longitude: draft.longitude ?? null,
favorite: draft.favorite,
departments: draft.departments
.map((department, index) => ({
id: department.id,
name: department.name,
sortOrder: department.sortOrder || index + 1,
}))
.sort((a, b) => a.sortOrder - b.sortOrder),
});
}
function addStore() {
if (!storeName.trim()) return;
const storeId = uid("store");
saveDraft({
id: storeId,
name: storeName.trim(),
address: storeAddress.trim() || null,
latitude: storeLat ? Number(storeLat.replace(",", ".")) : null,
longitude: storeLng ? Number(storeLng.replace(",", ".")) : null,
favorite: false,
departments: defaultDepartments(storeId),
});
setStoreName("");
setStoreAddress("");
setStoreLat("");
setStoreLng("");
}
function useCurrentLocation() {
if (!navigator.geolocation) {
setStatus("Standortfreigabe ist in diesem Browser nicht verfügbar.");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
setStoreLat(position.coords.latitude.toFixed(6));
setStoreLng(position.coords.longitude.toFixed(6));
setStatus("Standort übernommen. Bitte Marktname und Adresse ergänzen.");
},
() => setStatus("Standort konnte nicht gelesen werden."),
{ enableHighAccuracy: true, timeout: 10000 },
);
}
function toggleFavorite(store: StoreDraft) {
if (!store.id) return;
setStoreFavoriteMutation.mutate({ storeId: store.id, favorite: !store.favorite });
}
function submitChange(store: StoreDraft, requestType: "new_store" | "structure_update" | "location_update" = "structure_update") {
submitStoreChangeRequest.mutate({
storeId: store.id ?? null,
requestType,
title: `${store.name}: Änderung übernehmen`,
note: changeNotes[store.id ?? store.name] || null,
store: {
name: store.name,
address: store.address ?? null,
placeId: store.placeId ?? null,
latitude: store.latitude ?? null,
longitude: store.longitude ?? null,
},
departments: store.departments,
});
}
function addDepartment(store: StoreDraft) {
const name = departmentNames[store.id ?? store.name]?.trim();
if (!name) return;
const maxOrder = Math.max(0, ...store.departments.map((department) => department.sortOrder));
saveDraft({
...store,
departments: [
...store.departments,
{ id: uid(`${store.id ?? "store"}-dept`), name, sortOrder: maxOrder + 1 },
],
});
setDepartmentNames((current) => ({ ...current, [store.id ?? store.name]: "" }));
}
function removeDepartment(store: StoreDraft, departmentId: string) {
if (store.departments.length <= 1) return;
saveDraft({ ...store, departments: store.departments.filter((department) => department.id !== departmentId) });
}
function moveDepartment(store: StoreDraft, departmentId: string, direction: -1 | 1) {
const sorted = [...store.departments].sort((a, b) => a.sortOrder - b.sortOrder);
const index = sorted.findIndex((department) => department.id === departmentId);
const targetIndex = index + direction;
if (index < 0 || targetIndex < 0 || targetIndex >= sorted.length) return;
const current = sorted[index]!;
const target = sorted[targetIndex]!;
sorted[index] = { ...current, sortOrder: target.sortOrder };
sorted[targetIndex] = { ...target, sortOrder: current.sortOrder };
saveDraft({ ...store, departments: sorted });
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Einkaufen</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Märkte & Laufwege</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Speichere nahe Lebensmittelläden, pinne ihren Standort und sortiere die Abteilungen so, wie du wirklich durch den Markt läufst.
</p>
</header>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="grid gap-3 lg:grid-cols-[1fr_1.4fr_120px_120px_130px]">
<FormField label="Marktname">
<input value={storeName} onChange={(event) => setStoreName(event.target.value)} placeholder="z. B. Kaufland" className={inputClassName} />
</FormField>
<FormField label="Adresse">
<input value={storeAddress} onChange={(event) => setStoreAddress(event.target.value)} placeholder="Straße, Ort" className={inputClassName} />
</FormField>
<FormField label="Breite">
<input value={storeLat} onChange={(event) => setStoreLat(event.target.value)} placeholder="48.3705" className={inputClassName} />
</FormField>
<FormField label="Länge">
<input value={storeLng} onChange={(event) => setStoreLng(event.target.value)} placeholder="10.8978" className={inputClassName} />
</FormField>
<div className="flex items-end gap-2">
<button type="button" onClick={useCurrentLocation} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-100 text-gray-700" aria-label="Standort übernehmen" title="Standort übernehmen">
<MapPinIcon className="h-5 w-5" />
</button>
<button type="button" onClick={addStore} className="grid h-12 w-12 place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Markt speichern" title="Markt speichern">
<PlusIcon className="h-5 w-5" />
</button>
</div>
</div>
<div className="mt-3">
<a href={mapsSearchUrl(storeName || "Supermarkt", storeAddress)} target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 rounded-full bg-teal-50 px-4 py-2 text-xs font-black text-[#0F766E]">
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
In Google Maps suchen
</a>
</div>
</section>
<div className="grid gap-4 lg:grid-cols-2">
{stores.map((store) => {
const canEdit = store.ownerUserId !== null;
const draft: StoreDraft = {
id: store.id,
name: store.name,
address: store.address,
placeId: store.placeId,
latitude: store.latitude,
longitude: store.longitude,
favorite: store.favorite,
departments: store.departments.map((department) => ({
id: department.id,
name: department.name,
sortOrder: department.sortOrder,
})),
};
const departmentKey = store.id;
return (
<section key={store.id} className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-2xl font-black text-gray-950">{store.name}</h2>
<p className="text-sm font-bold text-gray-400">
{store.departments.length} Abteilungen
{!canEdit ? " · globale Vorlage" : " · eigener Markt"}
</p>
{store.address ? <p className="mt-1 text-sm font-bold text-gray-500">{store.address}</p> : null}
{store.latitude != null && store.longitude != null ? (
<a href={`https://www.google.com/maps/search/?api=1&query=${store.latitude},${store.longitude}`} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-xs font-black text-[#0F766E]">
<MapPinIcon className="h-4 w-4" />
Pin öffnen
</a>
) : null}
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => toggleFavorite(draft)} className={`rounded-full p-2 ${store.favorite ? "bg-amber-100 text-amber-600" : "bg-gray-100 text-gray-300"}`} aria-label="Favorit" title="Favorit">
<StarIcon className="h-5 w-5" />
</button>
{canEdit ? (
<button type="button" onClick={() => deleteStoreMutation.mutate({ id: store.id })} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Markt löschen" title="Markt löschen">
<TrashIcon className="h-5 w-5" />
</button>
) : null}
</div>
</div>
<div className="mt-5 space-y-2">
{[...store.departments].sort((a, b) => a.sortOrder - b.sortOrder).map((department, index) => (
<div key={department.id} className="grid grid-cols-[40px_1fr_96px_40px] items-center gap-2 rounded-2xl bg-gray-50 px-3 py-2">
<span className="grid h-8 w-8 place-items-center rounded-full bg-white text-sm font-black text-gray-400">{index + 1}</span>
<span className="text-sm font-black text-gray-950">{department.name}</span>
<div className="flex gap-1">
{canEdit ? (
<>
<button type="button" onClick={() => moveDepartment(draft, department.id, -1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">hoch</button>
<button type="button" onClick={() => moveDepartment(draft, department.id, 1)} className="rounded-full bg-white px-2 py-1 text-xs font-black text-gray-500">runter</button>
</>
) : null}
</div>
{canEdit ? (
<button type="button" onClick={() => removeDepartment(draft, department.id)} className="rounded-full p-2 text-red-500 hover:bg-red-50" aria-label="Abteilung löschen" title="Abteilung löschen">
<TrashIcon className="h-4 w-4" />
</button>
) : <span />}
</div>
))}
</div>
{canEdit ? (
<div className="mt-4 grid grid-cols-[1fr_44px] gap-2">
<input
value={departmentNames[departmentKey] ?? ""}
onChange={(event) => setDepartmentNames((current) => ({ ...current, [departmentKey]: event.target.value }))}
placeholder="Neue Abteilung"
className={inputClassName}
/>
<button type="button" onClick={() => addDepartment(draft)} className="grid place-items-center rounded-2xl bg-gray-950 text-white" aria-label="Abteilung hinzufügen" title="Abteilung hinzufügen">
<PlusIcon className="h-5 w-5" />
</button>
</div>
) : null}
<div className="mt-4 rounded-3xl bg-gray-50 p-4">
<FormField label="Änderung einreichen" hint={canEdit ? "Eigene Märkte können vom Admin als globale Vorlage übernommen werden." : "Globale Vorlagen werden nach Admin-Prüfung aktualisiert."}>
<textarea value={changeNotes[departmentKey] ?? ""} onChange={(event) => setChangeNotes((current) => ({ ...current, [departmentKey]: event.target.value }))} placeholder="Was hat sich im Markt geändert?" className={`${inputClassName} min-h-20`} />
</FormField>
<button type="button" onClick={() => submitChange(draft)} className="mt-3 w-full rounded-full bg-[#0F766E] px-4 py-3 text-sm font-black text-white">
Änderung zur Prüfung einreichen
</button>
</div>
</section>
);
})}
</div>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Meine Änderungsanträge</h2>
<div className="mt-3 grid gap-2">
{(requestsQuery.data ?? []).slice(0, 8).map((request) => (
<div key={request.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold">
<span className="min-w-0 truncate text-gray-950">{request.title}</span>
<span className={`rounded-full px-3 py-1 text-xs ${request.status === "accepted" ? "bg-emerald-100 text-emerald-700" : request.status === "rejected" ? "bg-red-100 text-red-700" : "bg-amber-100 text-amber-700"}`}>
{request.status === "accepted" ? "übernommen" : request.status === "rejected" ? "abgelehnt" : "in Prüfung"}
</span>
</div>
))}
{!(requestsQuery.data ?? []).length ? <p className="text-sm font-bold text-gray-400">Noch keine Änderungsanträge.</p> : null}
</div>
</section>
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
</div>
);
}