forked from a/lifeto-shop
183 lines
6.1 KiB
TypeScript
183 lines
6.1 KiB
TypeScript
import {
|
||
FloatingFocusManager,
|
||
FloatingOverlay,
|
||
FloatingPortal,
|
||
useClick,
|
||
useDismiss,
|
||
useFloating,
|
||
useInteractions,
|
||
useRole,
|
||
} from '@floating-ui/react'
|
||
import { useAtom, useSetAtom } from 'jotai'
|
||
import { useState } from 'react'
|
||
import {
|
||
clearItemSelectionActionAtom,
|
||
closeMoveConfirmationAtom,
|
||
type MoveItemsResult,
|
||
moveConfirmationAtom,
|
||
moveSelectedItemsAtom,
|
||
} from '@/state/atoms'
|
||
|
||
export function MoveConfirmationPopup() {
|
||
const [confirmationState] = useAtom(moveConfirmationAtom)
|
||
const closeConfirmation = useSetAtom(closeMoveConfirmationAtom)
|
||
const moveItems = useSetAtom(moveSelectedItemsAtom)
|
||
const clearSelection = useSetAtom(clearItemSelectionActionAtom)
|
||
const [isMoving, setIsMoving] = useState(false)
|
||
const [moveResult, setMoveResult] = useState<MoveItemsResult | null>(null)
|
||
|
||
const { refs, context } = useFloating({
|
||
open: confirmationState.isOpen,
|
||
onOpenChange: open => {
|
||
if (!open && !isMoving) {
|
||
closeConfirmation()
|
||
setMoveResult(null)
|
||
}
|
||
},
|
||
})
|
||
|
||
const click = useClick(context)
|
||
const dismiss = useDismiss(context, {
|
||
outsidePressEvent: 'mousedown',
|
||
escapeKey: !isMoving,
|
||
})
|
||
const role = useRole(context)
|
||
|
||
const { getFloatingProps } = useInteractions([click, dismiss, role])
|
||
|
||
if (!confirmationState.isOpen) return null
|
||
|
||
const { selectedItems, sourceCharacter, targetCharacter } = confirmationState
|
||
|
||
const handleConfirm = async () => {
|
||
setIsMoving(true)
|
||
try {
|
||
const result = await moveItems()
|
||
setMoveResult(result)
|
||
if (result.failedCount === 0) {
|
||
clearSelection()
|
||
setTimeout(() => {
|
||
closeConfirmation()
|
||
setMoveResult(null)
|
||
}, 1500)
|
||
}
|
||
} catch (_error) {
|
||
// Error handled in UI
|
||
} finally {
|
||
setIsMoving(false)
|
||
}
|
||
}
|
||
|
||
const handleCancel = () => {
|
||
if (!isMoving) {
|
||
closeConfirmation()
|
||
}
|
||
}
|
||
|
||
const renderItemPreview = () => {
|
||
const itemsArray = Array.from(selectedItems.values())
|
||
const totalUniqueItems = itemsArray.length
|
||
const totalQuantity = itemsArray.reduce((sum, { count }) => sum + count, 0)
|
||
|
||
if (totalUniqueItems > 5) {
|
||
return (
|
||
<div className="text-center py-4">
|
||
<p className="text-lg font-semibold">Moving {totalUniqueItems} different items</p>
|
||
<p className="text-sm text-gray-600">Total quantity: {totalQuantity.toLocaleString()}</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||
{itemsArray.map(({ item, count }) => (
|
||
<div
|
||
key={item.id}
|
||
className="flex items-center justify-between px-2 py-1 hover:bg-gray-50 rounded"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<img
|
||
src={item.item_image || ''}
|
||
alt={item.item_name}
|
||
className="w-6 h-6 object-contain"
|
||
/>
|
||
<span className="text-sm">{item.item_name}</span>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-600">×{count.toLocaleString()}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<FloatingPortal>
|
||
<FloatingOverlay className="grid place-items-center bg-black/50 z-50" lockScroll>
|
||
<FloatingFocusManager context={context} initialFocus={-1}>
|
||
<div
|
||
ref={refs.setFloating}
|
||
className="bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4"
|
||
{...getFloatingProps()}
|
||
>
|
||
<h2 className="text-xl font-bold mb-4">Confirm Item Movement</h2>
|
||
|
||
<div className="mb-4 space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">From:</span>
|
||
<span className="font-medium">{sourceCharacter?.name || 'Unknown'}</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-gray-600">To:</span>
|
||
<span className="font-medium">{targetCharacter?.name || 'Unknown'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t border-b border-gray-200 py-4 mb-4">{renderItemPreview()}</div>
|
||
|
||
{moveResult && (
|
||
<div
|
||
className={`mb-4 p-3 rounded ${moveResult.failedCount > 0 ? 'bg-yellow-50' : 'bg-green-50'}`}
|
||
>
|
||
<p className="text-sm font-medium">
|
||
{moveResult.failedCount === 0
|
||
? `Successfully moved ${moveResult.successCount} items!`
|
||
: `Moved ${moveResult.successCount} of ${moveResult.totalItems} items`}
|
||
</p>
|
||
{moveResult.errors.length > 0 && (
|
||
<div className="mt-2 text-xs text-red-600">
|
||
{moveResult.errors.slice(0, 3).map(error => (
|
||
<p key={error.itemId}>{error.error}</p>
|
||
))}
|
||
{moveResult.errors.length > 3 && (
|
||
<p>...and {moveResult.errors.length - 3} more errors</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-3 justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={handleCancel}
|
||
disabled={isMoving}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleConfirm}
|
||
disabled={isMoving || moveResult !== null}
|
||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isMoving ? 'Moving...' : 'Confirm Move'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</FloatingFocusManager>
|
||
</FloatingOverlay>
|
||
</FloatingPortal>
|
||
)
|
||
}
|