1923 lines
78 KiB
TypeScript
1923 lines
78 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 { 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, DialogTrigger } 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 [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();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── 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;
|
|
if (!confirm(`Delete ${selected.size} selected item(s)?`)) 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) {
|
|
if (!confirm("Delete this catalog item?")) 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-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-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-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-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 (
|
|
<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}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Item
|
|
</Button>
|
|
</DialogTrigger>
|
|
<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 "{searchQuery}".
|
|
</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">
|
|
{item.costCredits > 0 && (
|
|
<span className="text-[var(--admin-warning)]">
|
|
{item.costCredits}c{" "}
|
|
</span>
|
|
)}
|
|
{item.costPoints > 0 && (
|
|
<span className={pts.color}>
|
|
{item.costPoints} {pts.label}{" "}
|
|
</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-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-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-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-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-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-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>
|
|
);
|
|
}
|