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:
2026-05-31 23:54:02 +03:00
parent 607e78ea82
commit 18a75edf2a
2 changed files with 58 additions and 19 deletions

View File

@@ -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 typing import List
@@ -36,6 +36,13 @@ def create_group(body: TableGroupCreate, db: Session = Depends(get_db), user: Us
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)
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()

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import client from '../api/client'
@@ -43,6 +43,8 @@ export default function TablesPage() {
const [activeTab, setActiveTab] = useState('all') // 'all' | group.id
const [selected, setSelected] = useState(new Set())
const [anyHovered, setAnyHovered] = useState(false)
const dragGroupId = useRef(null)
const [dragOverId, setDragOverId] = useState(null)
const { data: tables = [], isLoading } = useQuery({
queryKey: ['tables-all', showInactive],
@@ -102,6 +104,12 @@ export default function TablesPage() {
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
const visibleTables = activeTab === 'all'
? tables
@@ -156,23 +164,47 @@ export default function TablesPage() {
{/* Zone tabs */}
<div className="flex gap-0 flex-wrap border-b border-slate-200 px-4 flex-shrink-0">
{zoneTabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); clearSelect() }}
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
? 'border-sky-500 text-sky-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />}
{tab.label}
<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})
</span>
</button>
))}
{zoneTabs.map(tab => {
const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
const isDragOver = dragOverId === tab.id
return (
<button
key={tab.id}
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 ${
activeTab === tab.id
? 'border-sky-500 text-sky-600'
: '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.label}
<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})
</span>
</button>
)
})}
</div>
{/* Zone action bar (when viewing a specific zone) */}