Feat: Geolocation

This commit is contained in:
2025-11-10 17:54:06 +01:00
parent 18ecbac77d
commit 41ed69a058

View File

@@ -1,6 +1,287 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table';
import NotificationPanel from './NotificationPanel'; import NotificationPanel from './NotificationPanel';
const CONFIG_TABLE_STATE_KEY = 'dashboardConfigTableState';
const columnHelper = createColumnHelper();
const ColumnTextFilter = ({ column, placeholder }) => {
if (!column.getCanFilter()) {
return null;
}
const configTableData = useMemo(() => {
return Array.isArray(visibleConfig)
? visibleConfig.map((item) => ({
...item,
normalizedLabel: (item.label || '').toLowerCase()
}))
: [];
}, [visibleConfig]);
const weekdaysOptions = useMemo(
() =>
weekdays.map((day) => ({
value: day,
label: day
})),
[weekdays]
);
const configColumns = useMemo(
() => [
columnHelper.display({
id: 'active',
header: () => <span>Aktiv</span>,
cell: ({ row }) => (
<div className="text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={row.original.active}
onChange={() => onToggleActive(row.original.id)}
/>
</div>
),
enableSorting: false,
enableColumnFilter: false
}),
columnHelper.accessor('label', {
header: ({ column }) => (
<div>
<button
type="button"
className="flex w-full items-center justify-between text-left font-semibold"
onClick={column.getToggleSortingHandler()}
>
<span>Betrieb</span>
{column.getIsSorted() ? (
<span className="text-xs text-gray-500">{column.getIsSorted() === 'asc' ? '▲' : '▼'}</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</button>
<ColumnTextFilter column={column} placeholder="Name / ID" />
</div>
),
cell: ({ row }) => (
<div>
<span className="font-medium">{row.original.label}</span>
{row.original.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
<p className="text-xs text-gray-500">#{row.original.id}</p>
</div>
),
sortingFn: 'alphanumeric',
filterFn: 'includesString'
}),
columnHelper.display({
id: 'checkProfileId',
header: () => <span>Profil prüfen</span>,
cell: ({ row }) => (
<div className="text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={row.original.checkProfileId}
onChange={() => onToggleProfileCheck(row.original.id)}
/>
</div>
),
enableSorting: false,
enableColumnFilter: false
}),
columnHelper.accessor((row) => row.onlyNotify, {
id: 'onlyNotify',
header: ({ column }) => (
<div>
<span className="font-semibold text-sm">Nur benachrichtigen</span>
<ColumnSelectFilter
column={column}
options={[
{ value: 'true', label: 'Ja' },
{ value: 'false', label: 'Nein' }
]}
/>
</div>
),
cell: ({ row }) => (
<div className="text-center">
<input
type="checkbox"
className="h-5 w-5"
checked={row.original.onlyNotify}
onChange={() => onToggleOnlyNotify(row.original.id)}
/>
</div>
),
filterFn: (row, columnId, value) => {
if (value === undefined) {
return true;
}
const boolValue = value === 'true';
return row.getValue(columnId) === boolValue;
},
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId);
const b = rowB.getValue(columnId);
return Number(b) - Number(a);
}
}),
columnHelper.accessor('desiredWeekday', {
header: ({ column }) => (
<div>
<span className="font-semibold text-sm">Wochentag</span>
<ColumnSelectFilter column={column} options={weekdaysOptions} placeholder="Alle Tage" />
</div>
),
cell: ({ row }) => (
<select
value={row.original.desiredWeekday || ''}
onChange={(event) => onWeekdayChange(row.original.id, event.target.value)}
className="border rounded p-1 w-full"
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>
{day}
</option>
))}
</select>
),
filterFn: (row, columnId, value) => {
if (!value) {
return true;
}
return (row.getValue(columnId) || '') === value;
},
sortingFn: 'alphanumeric'
}),
columnHelper.display({
id: 'dateRange',
header: () => <span>Datum / Zeitraum</span>,
cell: ({ row }) => {
const normalizedRange = row.original.desiredDateRange
? { ...row.original.desiredDateRange }
: row.original.desiredDate
? { start: row.original.desiredDate, end: row.original.desiredDate }
: null;
const rangeStart = normalizedRange?.start || '';
const rangeEnd = normalizedRange?.end || '';
return (
<button
type="button"
onClick={() => onRangePickerRequest(row.original.id)}
className="w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white hover:border-blue-400"
>
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
</button>
);
},
enableSorting: false,
enableColumnFilter: false
}),
columnHelper.display({
id: 'actions',
header: () => <span className="sr-only">Aktionen</span>,
cell: ({ row }) => (
<div className="inline-flex items-center justify-end gap-3 whitespace-nowrap w-full">
<button
type="button"
onClick={() => onHideEntry(row.original.id)}
className="text-gray-600 hover:text-gray-900"
title="Ausblenden"
>
<svg xmlns="http://www.w3.org/2000/svg" className="inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19c7 0 9-7 9-7s-2-7-9-7-9 7-9 7 2 7 9 7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
</svg>
</button>
{canDelete && (
<button
type="button"
onClick={() => onDeleteEntry(row.original.id)}
className="text-red-600 hover:text-red-800"
title="Löschen"
>
<svg xmlns="http://www.w3.org/2000/svg" className="inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
),
enableSorting: false,
enableColumnFilter: false
})
],
[
onDeleteEntry,
onHideEntry,
onRangePickerRequest,
onToggleActive,
onToggleOnlyNotify,
onToggleProfileCheck,
onWeekdayChange,
formatRangeLabel,
canDelete,
weekdays,
weekdaysOptions
]
);
const configTable = useReactTable({
data: configTableData,
columns: configColumns,
state: {
sorting: tableSorting,
columnFilters: tableFilters
},
onSortingChange: setTableSorting,
onColumnFiltersChange: setTableFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel()
});
return (
<input
type="text"
value={column.getFilterValue() ?? ''}
onChange={(event) => column.setFilterValue(event.target.value || undefined)}
placeholder={placeholder}
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
);
};
const ColumnSelectFilter = ({ column, options, placeholder = 'Alle' }) => {
if (!column.getCanFilter()) {
return null;
}
return (
<select
value={column.getFilterValue() ?? ''}
onChange={(event) => column.setFilterValue(event.target.value || undefined)}
className="mt-1 w-full rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
};
const DashboardView = ({ const DashboardView = ({
session, session,
onRefresh, onRefresh,
@@ -38,6 +319,27 @@ const DashboardView = ({
locationError, locationError,
onUpdateLocation onUpdateLocation
}) => { }) => {
const loadTableState = useCallback(() => {
if (typeof window === 'undefined') {
return { sorting: [], columnFilters: [] };
}
try {
const raw = window.localStorage.getItem(CONFIG_TABLE_STATE_KEY);
if (!raw) {
return { sorting: [], columnFilters: [] };
}
const parsed = JSON.parse(raw);
return {
sorting: Array.isArray(parsed.sorting) ? parsed.sorting : [],
columnFilters: Array.isArray(parsed.columnFilters) ? parsed.columnFilters : []
};
} catch {
return { sorting: [], columnFilters: [] };
}
}, []);
const initialTableState = loadTableState();
useEffect(() => { useEffect(() => {
if (!focusedStoreId) { if (!focusedStoreId) {
return; return;
@@ -60,6 +362,22 @@ const DashboardView = ({
}, [focusedStoreId, onClearFocus]); }, [focusedStoreId, onClearFocus]);
const [geoBusy, setGeoBusy] = useState(false); const [geoBusy, setGeoBusy] = useState(false);
const [geoError, setGeoError] = useState(''); const [geoError, setGeoError] = useState('');
const [tableSorting, setTableSorting] = useState(initialTableState.sorting);
const [tableFilters, setTableFilters] = useState(initialTableState.columnFilters);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(
CONFIG_TABLE_STATE_KEY,
JSON.stringify({ sorting: tableSorting, columnFilters: tableFilters })
);
} catch {
/* ignore */
}
}, [tableSorting, tableFilters]);
const handleDetectLocation = useCallback(() => { const handleDetectLocation = useCallback(() => {
if (!navigator.geolocation) { if (!navigator.geolocation) {
@@ -286,115 +604,31 @@ const DashboardView = ({
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full bg-white border border-gray-200"> <table className="min-w-full bg-white border border-gray-200">
<thead> <thead className="bg-gray-100">
<tr className="bg-gray-100"> {configTable.getHeaderGroups().map((headerGroup) => (
<th className="px-4 py-2">Aktiv</th> <tr key={headerGroup.id}>
<th className="px-4 py-2">Betrieb</th> {headerGroup.headers.map((header) => (
<th className="px-4 py-2">Profil prüfen</th> <th key={header.id} className="px-4 py-2 text-left align-top text-sm font-semibold">
<th className="px-4 py-2">Nur benachrichtigen</th> {flexRender(header.column.columnDef.header, header.getContext())}
<th className="px-4 py-2">Wochentag</th> </th>
<th className="px-4 py-2">Datum / Zeitraum</th> ))}
<th className="px-4 py-2 text-right">Aktionen</th>
</tr> </tr>
))}
</thead> </thead>
<tbody> <tbody>
{visibleConfig.map((item, index) => { {configTable.getRowModel().rows.map((row, idx) => (
const normalizedRange = item.desiredDateRange
? { ...item.desiredDateRange }
: item.desiredDate
? { start: item.desiredDate, end: item.desiredDate }
: null;
const rangeStart = normalizedRange?.start || '';
const rangeEnd = normalizedRange?.end || '';
return (
<tr <tr
key={item.id || index} key={row.id}
data-store-row={item.id} data-store-row={row.original.id}
className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'} className={idx % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
> >
<td className="px-4 py-2 text-center"> {row.getVisibleCells().map((cell) => (
<input <td key={cell.id} className="px-4 py-2 align-middle">
type="checkbox" {flexRender(cell.column.columnDef.cell, cell.getContext())}
checked={item.active}
onChange={() => onToggleActive(item.id)}
className="h-5 w-5"
/>
</td> </td>
<td className="px-4 py-2">
<span className="font-medium">{item.label}</span>
{item.hidden && <span className="ml-2 text-xs text-gray-400">(ausgeblendet)</span>}
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.checkProfileId}
onChange={() => onToggleProfileCheck(item.id)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2 text-center">
<input
type="checkbox"
checked={item.onlyNotify}
onChange={() => onToggleOnlyNotify(item.id)}
className="h-5 w-5"
/>
</td>
<td className="px-4 py-2">
<select
value={item.desiredWeekday || ''}
onChange={(event) => onWeekdayChange(item.id, event.target.value)}
className="border rounded p-1 w-full"
>
<option value="">Kein Wochentag</option>
{weekdays.map((day) => (
<option key={day} value={day}>
{day}
</option>
))} ))}
</select>
</td>
<td className="px-4 py-2">
<button
type="button"
onClick={() => onRangePickerRequest(item.id)}
className="w-full border rounded p-2 text-left transition focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white hover:border-blue-400"
>
<span className="block text-sm text-gray-700">{formatRangeLabel(rangeStart, rangeEnd)}</span>
<span className="block text-xs text-gray-500">Klicke zum Auswählen</span>
</button>
</td>
<td className="px-4 py-2 text-right">
<div className="inline-flex items-center justify-end gap-3 whitespace-nowrap">
<button
type="button"
onClick={() => onHideEntry(item.id)}
className="text-gray-600 hover:text-gray-900"
title="Ausblenden"
>
<svg xmlns="http://www.w3.org/2000/svg" className="inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19c7 0 9-7 9-7s-2-7-9-7-9 7-9 7 2 7 9 7z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3l18 18" />
</svg>
</button>
{canDelete && (
<button
type="button"
onClick={() => onDeleteEntry(item.id)}
className="text-red-600 hover:text-red-800"
title="Löschen"
>
<svg xmlns="http://www.w3.org/2000/svg" className="inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
)}
</div>
</td>
</tr> </tr>
); ))}
})}
</tbody> </tbody>
</table> </table>
</div> </div>