Files
EpicNext-Cms/src/app/admin/catalog/[id]/catalog-items-table/catalog-items-table.tsx
T
openhands 827595ef00
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m25s
fix: mobile responsiveness for all admin pages
- DataTable: overflow-x-auto on table wrapper (affects users, rooms, tickets)
- Radio: overflow-x-auto wrappers around all 3 tables (shouts, applications, schedules)
- Catalog items: responsive grids (grid-cols-1 sm:grid-cols-3) in Add/Edit dialogs
- Catalog tabs: TabsList overflow-x-auto for narrow screens
- Import clone: pagination flex-wrap to prevent overlap on small screens
2026-07-21 16:52:58 +02:00

2349 lines
70 KiB
TypeScript

"use client";
import {
closestCenter,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ArrowRightLeft,
Copy,
DollarSign,
GripVertical,
LayoutGrid,
Loader2,
MoveRight,
Pencil,
Plus,
RotateCcw,
Rows3,
Save,
Search,
Trash2,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createCatalogItem,
deleteCatalogItems,
moveCatalogItems,
reorderCatalogItems,
updateCatalogItem,
} from "@/actions/catalog-items";
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
import { ItemsShopPreview } from "@/components/admin/catalog/items-shop-preview";
import { QuickAddFurni } from "@/components/admin/catalog/quick-add-furni";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import {
CurrencyIcon,
currencyKindFromPointsType,
} from "@/components/shared/currency-icon";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useServerAction } from "@/hooks/use-server-action";
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
import { cn } from "@/lib/utils";
import {
InteractionTypeCombobox,
MovePageCombobox,
ToggleField,
} from "./field-helpers";
import { FurniIcon } from "./furni-icon";
import { SongPicker } from "./song-picker";
import type { BaseItemData, CatalogItemData, InlineEdit } from "./types";
import { DEFAULT_NEW_ITEM, getPointsLabel, POINTS_TYPES } from "./types";
export type { BaseItemData, CatalogItemData };
// ── Sortable row wrapper (dnd-kit) ─────────────────────────────
function SortableRow({
id,
className,
children,
}: {
id: number;
className?: string;
children: (props: {
dragHandleProps: React.HTMLAttributes<HTMLDivElement>;
}) => React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.35 : 1,
};
const dragHandleProps = {
...attributes,
...listeners,
} as unknown as React.HTMLAttributes<HTMLDivElement>;
return (
<TableRow
ref={setNodeRef as unknown as React.Ref<HTMLTableRowElement>}
style={style}
className={className}
>
{children({ dragHandleProps })}
</TableRow>
);
}
interface CatalogItemsTableProps {
items: CatalogItemData[];
pageId: number;
pageLayout: string;
canEdit: boolean;
interactionTypes: string[];
furniRevisionMap: Record<number, { classname: string; revision: number }>;
allPages: { id: number; caption: string }[];
}
export function CatalogItemsTable({
items,
pageId,
pageLayout,
canEdit,
interactionTypes,
furniRevisionMap,
allPages,
}: CatalogItemsTableProps) {
const router = useRouter();
const { run, isPending } = useServerAction();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [addOpen, setAddOpen] = useState(false);
const [editingItem, setEditingItem] = useState<CatalogItemData | null>(null);
const [editingBase, setEditingBase] = useState<BaseItemData | null>(null);
const [savingItemId, setSavingItemId] = useState<number | null>(null);
const [newItem, setNewItem] = useState({ ...DEFAULT_NEW_ITEM });
// ── View mode (table / grid) ────────────────────────────────────
const [viewMode, setViewMode] = useState<"table" | "grid">("table");
// ── Search / Filter ─────────────────────────────────────────────
const [searchQuery, setSearchQuery] = useState("");
const filteredItems = useMemo(() => {
if (!searchQuery.trim()) return items;
const q = searchQuery.toLowerCase();
return items.filter(
(item) =>
item.catalogName.toLowerCase().includes(q) ||
item.baseItemName.toLowerCase().includes(q) ||
item.baseName.toLowerCase().includes(q) ||
item.itemIds.includes(q) ||
String(item.id).includes(q),
);
}, [items, searchQuery]);
// ── Move items dialog ───────────────────────────────────────────
const [moveOpen, setMoveOpen] = useState(false);
const [moveTargetPageId, setMoveTargetPageId] = useState<number | null>(null);
// ── Bulk price dialog ───────────────────────────────────────────
const [bulkPriceOpen, setBulkPriceOpen] = useState(false);
const [bulkPriceField, setBulkPriceField] = useState<
"costCredits" | "costPoints"
>("costCredits");
const [bulkPriceMode, setBulkPriceMode] = useState<"set" | "add" | "percent">(
"set",
);
const [bulkPriceValue, setBulkPriceValue] = useState(0);
// ── Drag & Drop (dnd-kit) ───────────────────────────────────────
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const [itemOrder, setItemOrder] = useState<number[]>(items.map((i) => i.id));
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
);
const orderedFilteredItems = useMemo(() => {
if (searchQuery.trim()) return filteredItems;
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
return [...filteredItems].sort(
(a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0),
);
}, [filteredItems, itemOrder, searchQuery]);
// ── Selection ────────────────────────────────────────────────────
const [selected, setSelected] = useState<Set<number>>(new Set());
const toggleOne = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setSelected((prev) =>
prev.size === filteredItems.length
? new Set()
: new Set(filteredItems.map((i) => i.id)),
);
}, [filteredItems]);
// ── Inline edits ─────────────────────────────────────────────────
const [inlineEdits, setInlineEdits] = useState<Record<number, InlineEdit>>(
Object.fromEntries(
items.map((item) => [
item.id,
{
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
},
]),
),
);
function updateInline(
id: number,
key: keyof InlineEdit,
value: string | number,
) {
setInlineEdits((prev) => ({
...prev,
[id]: { ...prev[id], [key]: value },
}));
}
const isItemModified = useCallback(
(item: CatalogItemData) => {
const edit = inlineEdits[item.id];
if (!edit) return false;
return (
edit.costCredits !== item.costCredits ||
edit.costPoints !== item.costPoints ||
edit.pointsType !== item.pointsType ||
edit.amount !== item.amount
);
},
[inlineEdits],
);
const modifiedItems = useMemo(
() => items.filter(isItemModified),
[items, isItemModified],
);
const hasOrderChanges = useMemo(() => {
const originalOrder = items.map((i) => i.id);
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
}, [items, itemOrder]);
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
// Warn before navigating away with unsaved changes
useEffect(() => {
if (!hasUnsavedChanges) return;
function onBeforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [hasUnsavedChanges]);
// Ctrl+S / Cmd+S to save modified items
useEffect(() => {
if (!canEdit) return;
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (modifiedItems.length > 0) handleSaveAllModified();
else if (hasOrderChanges) handleSaveOrder();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
});
function resetInlineEdit(id: number) {
const item = items.find((i) => i.id === id);
if (!item) return;
setInlineEdits((prev) => ({
...prev,
[id]: {
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
},
}));
}
function resetAllEdits() {
setInlineEdits(
Object.fromEntries(
items.map((item) => [
item.id,
{
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
},
]),
),
);
}
// ── Open edit dialog ───────────────────────────────────────────
function openEditDialog(item: CatalogItemData) {
setEditingItem({ ...item });
setEditingBase(item.baseItem ? { ...item.baseItem } : null);
}
// ── Save single item inline ──────────────────────────────────────
async function handleSaveOne(item: CatalogItemData) {
const edit = inlineEdits[item.id];
if (!edit) return;
setSavingItemId(item.id);
run(
() =>
updateCatalogItem({
id: item.id,
catalogFields: {
catalogName: item.catalogName,
itemIds: item.itemIds,
costCredits: edit.costCredits,
costPoints: edit.costPoints,
pointsType: edit.pointsType,
amount: edit.amount,
limitedSells: item.limitedSells,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber,
offerId: item.offerId,
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
},
}),
{
successMessage: `Item #${item.id} saved.`,
errorMessage: `Failed to save item #${item.id}.`,
onSuccess: () => {
setSavingItemId(null);
router.refresh();
},
onError: () => setSavingItemId(null),
},
);
}
// ── Save all modified items ──────────────────────────────────────
async function handleSaveAllModified() {
if (modifiedItems.length === 0) return;
run(
async () => {
let savedCount = 0;
let failedCount = 0;
for (const item of modifiedItems) {
const edit = inlineEdits[item.id];
if (!edit) continue;
const result = await updateCatalogItem({
id: item.id,
catalogFields: {
catalogName: item.catalogName,
itemIds: item.itemIds,
costCredits: edit.costCredits,
costPoints: edit.costPoints,
pointsType: edit.pointsType,
amount: edit.amount,
limitedSells: item.limitedSells,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber,
offerId: item.offerId,
haveOffer: item.haveOffer,
clubOnly: item.clubOnly,
extradata: item.extradata,
},
});
if (result.ok) savedCount++;
else failedCount++;
}
if (failedCount > 0) {
return {
ok: false as const,
error: `Saved ${savedCount}, failed ${failedCount}.`,
};
}
return { ok: true as const, data: { savedCount } };
},
{
successMessage: `Saved ${modifiedItems.length} item(s).`,
errorMessage: "Failed to save items.",
},
);
}
// ── Bulk delete selected ─────────────────────────────────────────
async function handleBulkDelete() {
if (selected.size === 0) return;
const ok = await confirm({
title: "Delete items",
description: `Delete ${selected.size} selected item(s)?`,
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteCatalogItems({ ids: [...selected] }), {
successMessage: `Deleted ${selected.size} item(s).`,
errorMessage: "Failed to delete items.",
onSuccess: () => {
setSelected(new Set());
router.refresh();
},
});
}
// ── Move selected items to another page ─────────────────────────
async function handleMoveItems() {
if (selected.size === 0 || !moveTargetPageId) return;
// Record move patterns for all selected items
for (const id of selected) {
const item = items.find((i) => i.id === id);
if (item) {
recordMove({
interactionType: item.baseItem?.interactionType,
itemName: item.baseItemName,
targetPageId: moveTargetPageId,
});
}
}
run(
() =>
moveCatalogItems({
ids: [...selected],
targetPageId: moveTargetPageId,
}),
{
successMessage: `Moved ${selected.size} item(s) to page #${moveTargetPageId}.`,
errorMessage: "Failed to move items.",
onSuccess: () => {
setSelected(new Set());
setMoveOpen(false);
setMoveTargetPageId(null);
router.refresh();
},
},
);
}
// ── Apply bulk price update to inline edits ────────────────────
function applyBulkPrice() {
const targetIds =
selected.size > 0 ? selected : new Set(filteredItems.map((i) => i.id));
setInlineEdits((prev) => {
const next = { ...prev };
for (const id of targetIds) {
const edit = next[id];
if (!edit) continue;
const currentVal = edit[bulkPriceField];
let newVal: number;
if (bulkPriceMode === "set") {
newVal = bulkPriceValue;
} else if (bulkPriceMode === "add") {
newVal = currentVal + bulkPriceValue;
} else {
newVal = Math.round(currentVal * (1 + bulkPriceValue / 100));
}
next[id] = { ...edit, [bulkPriceField]: Math.max(0, newVal) };
}
return next;
});
setBulkPriceOpen(false);
toast.success(
`Price update applied to ${targetIds.size} item(s). Save to persist.`,
);
}
// ── Drag & Drop handlers (dnd-kit) ──────────────────────────────
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(Number(event.active.id));
}, []);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveDragId(null);
if (!over || active.id === over.id) return;
setItemOrder((prev) => {
const fromIdx = prev.indexOf(Number(active.id));
const toIdx = prev.indexOf(Number(over.id));
if (fromIdx === -1 || toIdx === -1) return prev;
return arrayMove(prev, fromIdx, toIdx);
});
}, []);
const handleDragCancel = useCallback(() => {
setActiveDragId(null);
}, []);
async function handleSaveOrder() {
const orders = itemOrder.map((id, idx) => ({ id, orderNumber: idx + 1 }));
run(() => reorderCatalogItems({ orders }), {
successMessage: "Item order saved.",
errorMessage: "Failed to save order.",
});
}
function resetOrder() {
setItemOrder(items.map((i) => i.id));
}
// Helper to get furni revision info for an item
function getFurniInfo(item: CatalogItemData) {
const firstBaseId = item.itemIds
.split(";")
.map(Number)
.find((n) => n > 0);
if (!firstBaseId) return { classname: item.baseItemName, revision: 0 };
const furni = furniRevisionMap[firstBaseId];
return furni
? {
classname: furni.classname || item.baseItemName,
revision: furni.revision,
}
: { classname: item.baseItemName, revision: 0 };
}
// ── Original CRUD handlers ───────────────────────────────────────
async function handleAddItem() {
run(
() =>
createCatalogItem({
...newItem,
pageId,
haveOffer: newItem.haveOffer as "0" | "1",
clubOnly: newItem.clubOnly as "0" | "1",
}),
{
successMessage: "Item added.",
errorMessage: "Failed to add item",
onSuccess: () => {
setAddOpen(false);
setNewItem({ ...DEFAULT_NEW_ITEM });
router.refresh();
},
},
);
}
async function handleDeleteItem(id: number) {
const ok = await confirm({
title: "Delete item",
description: "Delete this catalog item?",
confirmLabel: "Delete",
});
if (!ok) return;
run(() => deleteCatalogItems({ ids: [id] }), {
successMessage: "Item deleted.",
errorMessage: "Failed to delete item.",
});
}
// ── Move single item to another page ─────────────────────────
const [moveOneId, setMoveOneId] = useState<number | null>(null);
const [moveOneTarget, setMoveOneTarget] = useState<number | null>(null);
// Get the item being moved for suggestions
const moveOneItem = moveOneId ? items.find((i) => i.id === moveOneId) : null;
const moveOneSuggestions = useMemo(() => {
if (!moveOneItem) return [];
return getSuggestions({
interactionType: moveOneItem.baseItem?.interactionType,
itemName: moveOneItem.baseItemName,
});
}, [moveOneItem]);
async function handleMoveSingleItem() {
if (!moveOneId || !moveOneTarget) return;
// Record for learning before moving
if (moveOneItem) {
recordMove({
interactionType: moveOneItem.baseItem?.interactionType,
itemName: moveOneItem.baseItemName,
targetPageId: moveOneTarget,
});
}
run(
() => moveCatalogItems({ ids: [moveOneId], targetPageId: moveOneTarget }),
{
successMessage: `Item #${moveOneId} moved to page #${moveOneTarget}.`,
errorMessage: "Failed to move item.",
onSuccess: () => {
setMoveOneId(null);
setMoveOneTarget(null);
router.refresh();
},
},
);
}
async function handleDuplicateItem(item: CatalogItemData) {
run(
() =>
createCatalogItem({
pageId,
itemIds: item.itemIds,
catalogName: item.catalogName,
costCredits: item.costCredits,
costPoints: item.costPoints,
pointsType: item.pointsType,
amount: item.amount,
limitedSells: 0,
limitedStack: item.limitedStack,
orderNumber: item.orderNumber + 1,
offerId: item.offerId,
songId: item.songId,
haveOffer: item.haveOffer as "0" | "1",
clubOnly: item.clubOnly as "0" | "1",
extradata: item.extradata,
}),
{
successMessage: `Item #${item.id} duplicated.`,
errorMessage: "Failed to duplicate item.",
},
);
}
async function handleUpdateItem() {
if (!editingItem) return;
run(
() =>
updateCatalogItem({
id: editingItem.id,
catalogFields: {
catalogName: editingItem.catalogName,
itemIds: editingItem.itemIds,
costCredits: editingItem.costCredits,
costPoints: editingItem.costPoints,
pointsType: editingItem.pointsType,
amount: editingItem.amount,
limitedSells: editingItem.limitedSells,
limitedStack: editingItem.limitedStack,
orderNumber: editingItem.orderNumber,
offerId: editingItem.offerId,
songId: editingItem.songId,
haveOffer: editingItem.haveOffer,
clubOnly: editingItem.clubOnly,
extradata: editingItem.extradata,
},
...(editingBase
? {
baseItem: {
id: editingBase.id,
fields: editingBase as unknown as Record<string, unknown>,
},
}
: {}),
}),
{
successMessage: `Item #${editingItem.id} updated.`,
errorMessage: "Failed to update item",
onSuccess: () => {
setEditingItem(null);
setEditingBase(null);
router.refresh();
},
},
);
}
// ── Add Item Form Fields (for "Add" dialog) ───────────────────
function AddItemFormFields({
data,
onChange,
}: {
data: typeof DEFAULT_NEW_ITEM;
onChange: (key: string, value: string | number) => void;
}) {
return (
<div className="space-y-3">
<div className="space-y-2">
<Label>Catalog Name</Label>
<Input
value={data.catalogName}
onChange={(e) => onChange("catalogName", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Item IDs (semicolon-separated)</Label>
<Input
value={data.itemIds}
onChange={(e) => onChange("itemIds", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Credits</Label>
<Input
type="number"
value={data.costCredits}
onChange={(e) =>
onChange("costCredits", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points</Label>
<Input
type="number"
value={data.costPoints}
onChange={(e) =>
onChange("costPoints", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points Type</Label>
<Select
value={String(data.pointsType)}
onValueChange={(v) => onChange("pointsType", parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(POINTS_TYPES).map(([val, { label }]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Amount</Label>
<Input
type="number"
value={data.amount}
onChange={(e) =>
onChange("amount", parseInt(e.target.value, 10) || 1)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={data.orderNumber}
onChange={(e) =>
onChange("orderNumber", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Offer ID</Label>
<Input
type="number"
value={data.offerId}
onChange={(e) =>
onChange("offerId", parseInt(e.target.value, 10))
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Limited Sells</Label>
<Input
type="number"
value={data.limitedSells}
onChange={(e) =>
onChange("limitedSells", parseInt(e.target.value, 10) || 0)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Limited Stack</Label>
<Input
type="number"
value={data.limitedStack}
onChange={(e) =>
onChange("limitedStack", parseInt(e.target.value, 10) || 0)
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Have Offer</Label>
<Select
value={data.haveOffer}
onValueChange={(v) => onChange("haveOffer", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Yes</SelectItem>
<SelectItem value="0">No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Club Only</Label>
<Select
value={data.clubOnly}
onValueChange={(v) => onChange("clubOnly", v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No</SelectItem>
<SelectItem value="1">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Extra Data</Label>
<Input
value={data.extradata}
onChange={(e) => onChange("extradata", e.target.value)}
placeholder="Optional extra data"
/>
</div>
</div>
);
}
return (
<>
{confirmDialog}
<Card>
<CardHeader className="space-y-3">
<div className="flex flex-row items-center justify-between gap-4">
<div className="min-w-0">
<CardTitle>
Items ({items.length})
{searchQuery && filteredItems.length !== items.length && (
<span className="text-muted-foreground font-normal text-sm ml-2">
({filteredItems.length} shown)
</span>
)}
</CardTitle>
{selected.size > 0 && (
<p className="text-sm text-muted-foreground mt-1">
{selected.size} selected
</p>
)}
</div>
<div className="flex gap-2 flex-shrink-0 flex-wrap justify-end items-center">
{/* View toggle */}
<div className="flex items-center rounded-md border bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setViewMode("table")}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
viewMode === "table"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
title="Table view"
>
<Rows3 className="h-3.5 w-3.5" />
Table
</button>
<button
type="button"
onClick={() => setViewMode("grid")}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
viewMode === "grid"
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
title="Shop preview"
>
<LayoutGrid className="h-3.5 w-3.5" />
Preview
</button>
</div>
{canEdit && (
<>
{selected.size > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => setMoveOpen(true)}
disabled={isPending}
>
<ArrowRightLeft className="mr-2 h-4 w-4" />
Move ({selected.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleBulkDelete}
disabled={isPending}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete ({selected.size})
</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => setBulkPriceOpen(true)}
disabled={isPending}
>
<DollarSign className="mr-2 h-4 w-4" />
Bulk Price
</Button>
{hasOrderChanges && (
<>
<Button
variant="outline"
size="sm"
onClick={resetOrder}
disabled={isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Order
</Button>
<Button
size="sm"
onClick={handleSaveOrder}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Order
</Button>
</>
)}
{modifiedItems.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={resetAllEdits}
disabled={isPending}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reset
</Button>
<Button
size="sm"
onClick={handleSaveAllModified}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All ({modifiedItems.length})
</Button>
</>
)}
<QuickAddFurni
pageId={pageId}
furniRevisionMap={furniRevisionMap}
/>
<BulkImportItems
pageId={pageId}
onImported={() => router.refresh()}
/>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<Button size="sm" onClick={() => setAddOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Item
</Button>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Add Catalog Item</DialogTitle>
</DialogHeader>
<AddItemFormFields
data={newItem}
onChange={(key, value) =>
setNewItem((p) => ({ ...p, [key]: value }))
}
/>
<Button
onClick={handleAddItem}
disabled={isPending}
className="w-full"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Add Item
</Button>
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
{/* Unsaved changes banner */}
{hasUnsavedChanges && (
<div className="flex items-center gap-2 rounded-md border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-3 py-2 text-sm text-[var(--admin-warning)]">
<span className="flex-1">
You have unsaved changes (
{modifiedItems.length > 0
? `${modifiedItems.length} item(s) modified`
: ""}
{modifiedItems.length > 0 && hasOrderChanges ? ", " : ""}
{hasOrderChanges ? "order changed" : ""}). Press{" "}
<kbd className="rounded border bg-background px-1 font-mono text-xs">
Ctrl+S
</kbd>{" "}
to save.
</span>
</div>
)}
{/* Search bar */}
{items.length > 0 && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by name, classname, item ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9"
/>
{searchQuery && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
)}
</CardHeader>
<CardContent>
{items.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm text-muted-foreground">
No items in this page.
</p>
{canEdit && (
<Button
variant="outline"
size="sm"
onClick={() => setAddOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add First Item
</Button>
)}
</div>
) : filteredItems.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No items match &quot;{searchQuery}&quot;.
</p>
) : viewMode === "grid" ? (
<ItemsShopPreview
items={orderedFilteredItems}
furniRevisionMap={furniRevisionMap}
layout={pageLayout}
canEdit={canEdit}
onItemClick={canEdit ? (item) => openEditDialog(item) : undefined}
/>
) : (
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="rounded-md border overflow-auto max-h-[600px]">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
{canEdit && <TableHead className="w-[30px] px-1" />}
{canEdit && (
<TableHead className="w-[40px] px-2">
<Checkbox
checked={
filteredItems.length > 0 &&
selected.size === filteredItems.length
}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</TableHead>
)}
<TableHead className="w-[50px] px-1" />
<TableHead className="w-[60px]">ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Item IDs</TableHead>
<TableHead>Item Name</TableHead>
<TableHead className="min-w-[180px]">Price</TableHead>
<TableHead className="w-[70px]">Amt</TableHead>
<TableHead>Status</TableHead>
{canEdit && (
<TableHead className="w-[100px]">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<SortableContext
items={orderedFilteredItems.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<TableBody>
{orderedFilteredItems.map((item) => {
const pts = getPointsLabel(item.pointsType);
const edit = inlineEdits[item.id];
const modified = isItemModified(item);
const isSaving = savingItemId === item.id;
const furniInfo = getFurniInfo(item);
const sortableDisabled = !canEdit || !!searchQuery;
return (
<SortableRow
key={item.id}
id={item.id}
className={cn(
modified &&
"bg-[var(--admin-warning-subtle)] dark:bg-[var(--admin-warning-subtle)]",
)}
>
{({ dragHandleProps }) => (
<>
{canEdit && (
<TableCell className="px-1">
{!sortableDisabled ? (
<div
{...dragHandleProps}
className="flex items-center justify-center text-muted-foreground hover:text-foreground cursor-grab active:cursor-grabbing"
title="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
</div>
) : (
<div className="h-4 w-4" />
)}
</TableCell>
)}
{canEdit && (
<TableCell className="px-2">
<Checkbox
checked={selected.has(item.id)}
onCheckedChange={() => toggleOne(item.id)}
aria-label={`Select item ${item.id}`}
/>
</TableCell>
)}
<TableCell className="px-1">
<FurniIcon
classname={furniInfo.classname}
revision={furniInfo.revision}
/>
</TableCell>
<TableCell className="font-mono text-xs">
{item.id}
</TableCell>
<TableCell className="font-medium text-sm max-w-[180px] truncate">
{item.catalogName || (
<span className="text-muted-foreground italic">
unnamed
</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{item.itemIds}
</TableCell>
<TableCell className="text-xs text-muted-foreground font-mono max-w-[140px] truncate">
{item.baseItemName || "-"}
</TableCell>
<TableCell>
{canEdit ? (
<div className="flex items-center gap-1">
<Input
type="number"
value={
edit?.costCredits ?? item.costCredits
}
onChange={(e) =>
updateInline(
item.id,
"costCredits",
parseInt(e.target.value, 10) || 0,
)
}
className="h-8 text-xs w-16"
title="Credits"
/>
<span className="text-xs text-[var(--admin-warning)]">
c
</span>
<Input
type="number"
value={
edit?.costPoints ?? item.costPoints
}
onChange={(e) =>
updateInline(
item.id,
"costPoints",
parseInt(e.target.value, 10) || 0,
)
}
className="h-8 text-xs w-16"
title="Points"
/>
<span className={`text-xs ${pts.color}`}>
{pts.label.charAt(0)}
</span>
</div>
) : (
<div className="text-xs flex flex-wrap items-center gap-1.5">
{item.costCredits > 0 && (
<span className="inline-flex items-center gap-0.5 text-[var(--admin-warning)]">
<CurrencyIcon
kind="credits"
size={12}
/>
{item.costCredits}
</span>
)}
{item.costPoints > 0 && (
<span
className={`inline-flex items-center gap-0.5 ${pts.color}`}
>
<CurrencyIcon
kind={currencyKindFromPointsType(
item.pointsType,
)}
size={12}
/>
{item.costPoints}
</span>
)}
{item.costCredits === 0 &&
item.costPoints === 0 && (
<span className="text-muted-foreground">
Free
</span>
)}
</div>
)}
</TableCell>
<TableCell>
{canEdit ? (
<Input
type="number"
value={edit?.amount ?? item.amount}
onChange={(e) =>
updateInline(
item.id,
"amount",
parseInt(e.target.value, 10) || 1,
)
}
className="h-8 text-xs w-16"
/>
) : (
<span className="text-xs">
{item.amount}
</span>
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Badge
variant={
item.haveOffer === "1"
? "default"
: "outline"
}
className="text-xs"
>
{item.haveOffer === "1"
? "Offer"
: "No Offer"}
</Badge>
{item.limitedStack > 0 && (
<Badge
variant="secondary"
className="text-xs"
>
LTD {item.limitedSells}/
{item.limitedStack}
</Badge>
)}
{item.clubOnly === "1" && (
<Badge
variant="secondary"
className="text-xs"
>
HC
</Badge>
)}
</div>
</TableCell>
{canEdit && (
<TableCell>
<div className="flex gap-1">
{modified && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-[var(--admin-success)]"
onClick={() => handleSaveOne(item)}
disabled={isSaving || isPending}
title="Save this item"
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
resetInlineEdit(item.id)
}
title="Reset changes"
>
<RotateCcw className="h-3 w-3" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openEditDialog(item)}
title="Edit all fields"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() =>
handleDuplicateItem(item)
}
disabled={isPending}
title="Duplicate item"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setMoveOneId(item.id)}
disabled={isPending}
title="Move to another page"
>
<MoveRight className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() =>
handleDeleteItem(item.id)
}
disabled={isPending}
title="Delete"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
)}
</>
)}
</SortableRow>
);
})}
</TableBody>
</SortableContext>
</Table>
</div>
<DragOverlay>
{activeDragId !== null &&
(() => {
const dragged = items.find((i) => i.id === activeDragId);
if (!dragged) return null;
const info = getFurniInfo(dragged);
return (
<div className="flex items-center gap-3 rounded-md border bg-card px-3 py-2 shadow-lg">
<FurniIcon
classname={info.classname}
revision={info.revision}
/>
<div className="min-w-0">
<p className="truncate text-sm font-medium">
{dragged.catalogName ||
dragged.baseName ||
`#${dragged.id}`}
</p>
<p className="font-mono text-[10px] text-muted-foreground">
#{dragged.id}
</p>
</div>
</div>
);
})()}
</DragOverlay>
</DndContext>
)}
{/* ── Edit Item Dialog (tabbed: Catalog Settings + Furniture Settings) ── */}
{editingItem && (
<Dialog
open={!!editingItem}
onOpenChange={(open) => {
if (!open) {
setEditingItem(null);
setEditingBase(null);
}
}}
>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Item #{editingItem.id}</DialogTitle>
</DialogHeader>
{/* Read-only identity fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Catalog Name
</Label>
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
{editingItem.catalogName || (
<span className="italic text-muted-foreground">
unnamed
</span>
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
Item IDs
</Label>
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm font-mono">
{editingItem.itemIds}
</div>
</div>
</div>
<Tabs defaultValue="catalog" className="w-full">
<TabsList className="w-full">
<TabsTrigger value="catalog" className="flex-1">
Catalog Settings
</TabsTrigger>
<TabsTrigger
value="furniture"
className="flex-1"
disabled={!editingBase}
>
Furniture Settings
{!editingBase && (
<span className="ml-1 text-xs text-muted-foreground">
(N/A)
</span>
)}
</TabsTrigger>
</TabsList>
{/* ── Catalog Settings Tab ── */}
<TabsContent value="catalog" className="space-y-3 mt-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Credits</Label>
<Input
type="number"
value={editingItem.costCredits}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
costCredits:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points</Label>
<Input
type="number"
value={editingItem.costPoints}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
costPoints: parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Points Type</Label>
<Select
value={String(editingItem.pointsType)}
onValueChange={(v) =>
setEditingItem(
(p) => p && { ...p, pointsType: parseInt(v, 10) },
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(POINTS_TYPES).map(
([val, { label }]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Amount</Label>
<Input
type="number"
value={editingItem.amount}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
amount: parseInt(e.target.value, 10) || 1,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Order</Label>
<Input
type="number"
value={editingItem.orderNumber}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
orderNumber:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Offer ID</Label>
<Input
type="number"
value={editingItem.offerId}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
offerId: parseInt(e.target.value, 10),
},
)
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Limited Sells</Label>
<Input
type="number"
value={editingItem.limitedSells}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
limitedSells:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Limited Stack</Label>
<Input
type="number"
value={editingItem.limitedStack}
onChange={(e) =>
setEditingItem(
(p) =>
p && {
...p,
limitedStack:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Have Offer</Label>
<Select
value={editingItem.haveOffer}
onValueChange={(v) =>
setEditingItem((p) => p && { ...p, haveOffer: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Yes</SelectItem>
<SelectItem value="0">No</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Club Only</Label>
<Select
value={editingItem.clubOnly}
onValueChange={(v) =>
setEditingItem((p) => p && { ...p, clubOnly: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">No</SelectItem>
<SelectItem value="1">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Extra Data</Label>
<Input
value={editingItem.extradata}
onChange={(e) =>
setEditingItem(
(p) => p && { ...p, extradata: e.target.value },
)
}
placeholder="Optional extra data"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Song Disk</Label>
<SongPicker
value={editingItem.songId}
onChange={(id) =>
setEditingItem((p) => p && { ...p, songId: id })
}
/>
<p className="text-xs text-muted-foreground">
Select a soundtrack to play when this item is used
in-game. Uploaded from <code>/admin/sounds</code>.
</p>
</div>
</TabsContent>
{/* ── Furniture Settings Tab (items_base) ── */}
{editingBase && (
<TabsContent value="furniture" className="space-y-4 mt-3">
{/* Identity */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Identity
</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Base ID</Label>
<div className="rounded-md border bg-muted/50 px-3 py-2 text-sm font-mono">
{editingBase.id}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Sprite ID</Label>
<Input
type="number"
value={editingBase.spriteId}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
spriteId:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Type</Label>
<Select
value={editingBase.type}
onValueChange={(v) =>
setEditingBase((p) => p && { ...p, type: v })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s">s (Floor)</SelectItem>
<SelectItem value="i">i (Wall)</SelectItem>
<SelectItem value="e">e (Effect)</SelectItem>
<SelectItem value="b">b (Badge)</SelectItem>
<SelectItem value="r">r (Robot)</SelectItem>
<SelectItem value="h">
h (Habbo Club)
</SelectItem>
<SelectItem value="p">p (Pet)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Dimensions */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Dimensions
</h4>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-1">
<Label className="text-xs">Width</Label>
<Input
type="number"
value={editingBase.width}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
width: parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Length</Label>
<Input
type="number"
value={editingBase.length}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
length: parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Stack Height</Label>
<Input
type="number"
step="0.01"
value={editingBase.stackHeight}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
stackHeight:
parseFloat(e.target.value) || 0,
},
)
}
/>
</div>
</div>
</div>
{/* Interaction */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Interaction
</h4>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Interaction Type</Label>
<InteractionTypeCombobox
value={editingBase.interactionType}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, interactionType: v },
)
}
types={interactionTypes}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Interaction Modes</Label>
<Input
type="number"
value={editingBase.interactionModesCount}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
interactionModesCount:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
</div>
</div>
{/* Permissions */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Permissions
</h4>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border p-3">
<ToggleField
label="Allow Stack"
value={editingBase.allowStack}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowStack: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Sit"
value={editingBase.allowSit}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowSit: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Lay"
value={editingBase.allowLay}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowLay: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Walk"
value={editingBase.allowWalk}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowWalk: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Gift"
value={editingBase.allowGift}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowGift: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Trade"
value={editingBase.allowTrade}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowTrade: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Recycle"
value={editingBase.allowRecycle}
onChange={(v) =>
setEditingBase(
(p) => p && { ...p, allowRecycle: v ? 1 : 0 },
)
}
/>
<ToggleField
label="Allow Marketplace Sell"
value={editingBase.allowMarketplaceSell}
onChange={(v) =>
setEditingBase(
(p) =>
p && {
...p,
allowMarketplaceSell: v ? 1 : 0,
},
)
}
/>
<ToggleField
label="Allow Inventory Stack"
value={editingBase.allowInventoryStack}
onChange={(v) =>
setEditingBase(
(p) =>
p && { ...p, allowInventoryStack: v ? 1 : 0 },
)
}
/>
</div>
</div>
{/* Extra */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Extra
</h4>
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Vending IDs</Label>
<Input
value={editingBase.vendingIds}
onChange={(e) =>
setEditingBase(
(p) =>
p && { ...p, vendingIds: e.target.value },
)
}
placeholder="0"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Custom Params</Label>
<Input
value={editingBase.customparams}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
customparams: e.target.value,
},
)
}
placeholder=""
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Effect ID Male</Label>
<Input
type="number"
value={editingBase.effectIdMale}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
effectIdMale:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
Effect ID Female
</Label>
<Input
type="number"
value={editingBase.effectIdFemale}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
effectIdFemale:
parseInt(e.target.value, 10) || 0,
},
)
}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Clothing On Walk</Label>
<Input
value={editingBase.clothingOnWalk}
onChange={(e) =>
setEditingBase(
(p) =>
p && {
...p,
clothingOnWalk: e.target.value,
},
)
}
placeholder=""
/>
</div>
</div>
</div>
</TabsContent>
)}
</Tabs>
{/* Action buttons */}
<div className="flex gap-2 mt-2">
<Button
onClick={handleUpdateItem}
disabled={isPending}
className="flex-1"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
<Button
variant="outline"
onClick={() => {
setEditingItem(null);
setEditingBase(null);
}}
>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
)}
{/* ── Move Items Dialog ── */}
<Dialog
open={moveOpen}
onOpenChange={(open) => {
if (!open) {
setMoveOpen(false);
setMoveTargetPageId(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
Move {selected.size} item(s) to another page
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<Label>Target Page</Label>
<MovePageCombobox
value={moveTargetPageId}
onChange={setMoveTargetPageId}
pages={allPages}
currentPageId={pageId}
/>
<div className="flex gap-2">
<Button
onClick={handleMoveItems}
disabled={isPending || !moveTargetPageId}
className="flex-1"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowRightLeft className="mr-2 h-4 w-4" />
)}
Move Items
</Button>
<Button
variant="outline"
onClick={() => {
setMoveOpen(false);
setMoveTargetPageId(null);
}}
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* ── Move Single Item Dialog ── */}
<Dialog
open={moveOneId !== null}
onOpenChange={(open) => {
if (!open) {
setMoveOneId(null);
setMoveOneTarget(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Item to another page</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* Item info */}
{moveOneItem && (
<div className="flex items-center gap-3 rounded-md border bg-muted/30 px-3 py-2">
<FurniIcon
classname={getFurniInfo(moveOneItem).classname}
revision={getFurniInfo(moveOneItem).revision}
/>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{moveOneItem.catalogName || moveOneItem.baseName}
</p>
<p className="text-xs text-muted-foreground font-mono">
#{moveOneItem.id} · {moveOneItem.baseItemName || "—"}
{moveOneItem.baseItem?.interactionType &&
` · ${moveOneItem.baseItem.interactionType}`}
</p>
</div>
</div>
)}
<Label>Target Page</Label>
<MovePageCombobox
value={moveOneTarget}
onChange={setMoveOneTarget}
pages={allPages}
currentPageId={pageId}
suggestedPageIds={moveOneSuggestions}
/>
{moveOneSuggestions.length > 0 && (
<p className="text-[10px] text-muted-foreground">
Suggestions based on previous moves for similar items.
</p>
)}
<div className="flex gap-2">
<Button
onClick={handleMoveSingleItem}
disabled={isPending || !moveOneTarget}
className="flex-1"
>
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MoveRight className="mr-2 h-4 w-4" />
)}
Move Item
</Button>
<Button
variant="outline"
onClick={() => {
setMoveOneId(null);
setMoveOneTarget(null);
}}
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* ── Bulk Price Dialog ── */}
<Dialog open={bulkPriceOpen} onOpenChange={setBulkPriceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Bulk Price Update</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{selected.size > 0
? `Apply to ${selected.size} selected item(s).`
: `Apply to all ${filteredItems.length} visible item(s).`}
</p>
<div className="space-y-3">
<div className="space-y-2">
<Label>Field</Label>
<Select
value={bulkPriceField}
onValueChange={(v) =>
setBulkPriceField(v as "costCredits" | "costPoints")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="costCredits">Credits</SelectItem>
<SelectItem value="costPoints">Points</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operation</Label>
<Select
value={bulkPriceMode}
onValueChange={(v) =>
setBulkPriceMode(v as "set" | "add" | "percent")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="set">Set to value</SelectItem>
<SelectItem value="add">Add value</SelectItem>
<SelectItem value="percent">
Change by percentage
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
{bulkPriceMode === "percent" ? "Percentage (%)" : "Value"}
</Label>
<Input
type="number"
value={bulkPriceValue}
onChange={(e) =>
setBulkPriceValue(parseFloat(e.target.value) || 0)
}
placeholder={
bulkPriceMode === "percent"
? "e.g. 10 for +10%"
: "e.g. 5"
}
/>
{bulkPriceMode === "percent" && (
<p className="text-xs text-muted-foreground">
Use negative values to decrease (e.g. -20 for -20%)
</p>
)}
{bulkPriceMode === "add" && (
<p className="text-xs text-muted-foreground">
Use negative values to subtract
</p>
)}
</div>
<div className="flex gap-2">
<Button onClick={applyBulkPrice} className="flex-1">
<DollarSign className="mr-2 h-4 w-4" />
Apply
</Button>
<Button
variant="outline"
onClick={() => setBulkPriceOpen(false)}
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</>
);
}