Feat: Geolocation
This commit is contained in:
@@ -1,6 +1,287 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable
|
||||
} from '@tanstack/react-table';
|
||||
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 = ({
|
||||
session,
|
||||
onRefresh,
|
||||
@@ -38,6 +319,27 @@ const DashboardView = ({
|
||||
locationError,
|
||||
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(() => {
|
||||
if (!focusedStoreId) {
|
||||
return;
|
||||
@@ -60,6 +362,22 @@ const DashboardView = ({
|
||||
}, [focusedStoreId, onClearFocus]);
|
||||
const [geoBusy, setGeoBusy] = useState(false);
|
||||
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(() => {
|
||||
if (!navigator.geolocation) {
|
||||
@@ -284,117 +602,33 @@ const DashboardView = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full bg-white border border-gray-200">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-2">Aktiv</th>
|
||||
<th className="px-4 py-2">Betrieb</th>
|
||||
<th className="px-4 py-2">Profil prüfen</th>
|
||||
<th className="px-4 py-2">Nur benachrichtigen</th>
|
||||
<th className="px-4 py-2">Wochentag</th>
|
||||
<th className="px-4 py-2">Datum / Zeitraum</th>
|
||||
<th className="px-4 py-2 text-right">Aktionen</th>
|
||||
</tr>
|
||||
<thead className="bg-gray-100">
|
||||
{configTable.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<th key={header.id} className="px-4 py-2 text-left align-top text-sm font-semibold">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleConfig.map((item, index) => {
|
||||
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
|
||||
key={item.id || index}
|
||||
data-store-row={item.id}
|
||||
className={index % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
|
||||
>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.active}
|
||||
onChange={() => onToggleActive(item.id)}
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
{configTable.getRowModel().rows.map((row, idx) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
data-store-row={row.original.id}
|
||||
className={idx % 2 === 0 ? 'bg-gray-50' : 'bg-white'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<td key={cell.id} className="px-4 py-2 align-middle">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user