feat: drag-to-reorder table groups in zone tabs
Backend:
- tables.py: PUT /api/tables/groups/reorder accepts an ordered list
of group IDs and updates sort_order accordingly
Frontend (TablesConfigTab):
- Zone tabs are now draggable for group tabs (not All/Ungrouped)
- HTML5 drag-and-drop with visual drop-target highlight and a
grab cursor; fires reorderGroups mutation on drop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -36,6 +36,13 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
|
|||||||
return group
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/groups/reorder", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def reorder_groups(body: List[int] = Body(...), db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
|
for idx, group_id in enumerate(body):
|
||||||
|
db.query(TableGroup).filter(TableGroup.id == group_id).update({"sort_order": idx})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
@router.put("/groups/{group_id}", response_model=TableGroupOut)
|
||||||
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
def update_group(group_id: int, body: TableGroupUpdate, db: Session = Depends(get_db), user: User = Depends(require_manager)):
|
||||||
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
group = db.query(TableGroup).filter(TableGroup.id == group_id).first()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import client from '../api/client'
|
import client from '../api/client'
|
||||||
@@ -43,6 +43,8 @@ export default function TablesPage() {
|
|||||||
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
|
||||||
const [selected, setSelected] = useState(new Set())
|
const [selected, setSelected] = useState(new Set())
|
||||||
const [anyHovered, setAnyHovered] = useState(false)
|
const [anyHovered, setAnyHovered] = useState(false)
|
||||||
|
const dragGroupId = useRef(null)
|
||||||
|
const [dragOverId, setDragOverId] = useState(null)
|
||||||
|
|
||||||
const { data: tables = [], isLoading } = useQuery({
|
const { data: tables = [], isLoading } = useQuery({
|
||||||
queryKey: ['tables-all', showInactive],
|
queryKey: ['tables-all', showInactive],
|
||||||
@@ -102,6 +104,12 @@ export default function TablesPage() {
|
|||||||
onError: () => toast.error('Σφάλμα'),
|
onError: () => toast.error('Σφάλμα'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reorderGroups = useMutation({
|
||||||
|
mutationFn: (ids) => client.put('/api/tables/groups/reorder', ids),
|
||||||
|
onSuccess: invalidateGroups,
|
||||||
|
onError: () => toast.error('Σφάλμα αναδιάταξης'),
|
||||||
|
})
|
||||||
|
|
||||||
// Filter tables for the active tab
|
// Filter tables for the active tab
|
||||||
const visibleTables = activeTab === 'all'
|
const visibleTables = activeTab === 'all'
|
||||||
? tables
|
? tables
|
||||||
@@ -156,23 +164,47 @@ export default function TablesPage() {
|
|||||||
|
|
||||||
{/* Zone tabs */}
|
{/* Zone tabs */}
|
||||||
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
|
||||||
{zoneTabs.map(tab => (
|
{zoneTabs.map(tab => {
|
||||||
|
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
|
||||||
|
const isDragOver = dragOverId === tab.id
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
onClick={() => { setActiveTab(tab.id); clearSelect() }}
|
||||||
|
draggable={isGroup}
|
||||||
|
onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
|
||||||
|
onDragOver={isGroup ? (e) => { e.preventDefault(); setDragOverId(tab.id) } : undefined}
|
||||||
|
onDragLeave={isGroup ? () => setDragOverId(null) : undefined}
|
||||||
|
onDrop={isGroup ? (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverId(null)
|
||||||
|
const fromId = dragGroupId.current
|
||||||
|
if (!fromId || fromId === tab.id) return
|
||||||
|
const groupIds = groups.map(g => g.id)
|
||||||
|
const fromIdx = groupIds.indexOf(fromId)
|
||||||
|
const toIdx = groupIds.indexOf(tab.id)
|
||||||
|
if (fromIdx === -1 || toIdx === -1) return
|
||||||
|
const reordered = [...groupIds]
|
||||||
|
reordered.splice(fromIdx, 1)
|
||||||
|
reordered.splice(toIdx, 0, fromId)
|
||||||
|
reorderGroups.mutate(reordered)
|
||||||
|
} : undefined}
|
||||||
|
onDragEnd={isGroup ? () => { dragGroupId.current = null; setDragOverId(null) } : undefined}
|
||||||
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-sky-500 text-sky-600'
|
? 'border-sky-500 text-sky-600'
|
||||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||||
}`}
|
} ${isDragOver ? 'bg-sky-50 border-sky-300' : ''} ${isGroup ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
|
{isGroup && <span className="text-slate-300 text-xs mr-0.5 select-none">⠿</span>}
|
||||||
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
<span className="ml-0.5 text-xs text-slate-400">
|
<span className="ml-0.5 text-xs text-slate-400">
|
||||||
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length})
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zone action bar (when viewing a specific zone) */}
|
{/* Zone action bar (when viewing a specific zone) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user