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 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()

View File

@@ -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) */}