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 => {
<button const isGroup = tab.id !== 'all' && tab.id !== 'ungrouped'
key={tab.id} const isDragOver = dragOverId === tab.id
onClick={() => { setActiveTab(tab.id); clearSelect() }} return (
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${ <button
activeTab === tab.id key={tab.id}
? 'border-sky-500 text-sky-600' onClick={() => { setActiveTab(tab.id); clearSelect() }}
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300' draggable={isGroup}
}`} onDragStart={isGroup ? () => { dragGroupId.current = tab.id } : undefined}
> onDragOver={isGroup ? (e) => { e.preventDefault(); setDragOverId(tab.id) } : undefined}
{tab.color && <span className="w-2 h-2 rounded-full shrink-0" style={{ background: tab.color }} />} onDragLeave={isGroup ? () => setDragOverId(null) : undefined}
{tab.label} onDrop={isGroup ? (e) => {
<span className="ml-0.5 text-xs text-slate-400"> e.preventDefault()
({tab.id === 'all' ? tables.length : tab.id === 'ungrouped' ? tables.filter(t => !t.group_id).length : tables.filter(t => t.group_id === tab.id).length}) setDragOverId(null)
</span> const fromId = dragGroupId.current
</button> 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> </div>
{/* Zone action bar (when viewing a specific zone) */} {/* Zone action bar (when viewing a specific zone) */}