Feat: Geolocation
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user