The previous commit made imports update the Studio without a reload, but the guarantee only held inside the tab that started the import and only as long as every read succeeded. Four holes were left, and this closes them. A session that mounted the tree before an import kept the pre-import tree for the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session no-op. It now asks the server whether what it holds is still current. The answer is a revision: sendCatalogUpdate() already runs after every catalog write, so it bumps one, and clients read it on mount, on focus, on a 20s poll and from other tabs over a BroadcastChannel. An import that finishes in another tab, another browser or the job worker now lands here too. A failed read used to be swallowed, which is the worst outcome available: the rail kept showing pre-import counts as if they were current and nothing said so. The snapshot now carries the error, the rail shows it with a retry, and the previous tree stays on screen because stale beats empty. Every settled import pulled the entire flat tree, which is the one payload that grows with the size of the catalog. The revision doubles as the ETag on mode=full, so an unchanged catalog answers 304 and the poll costs a file read. An import could also report success for an offer the hotel will never sell: a hidden or disabled page, an item_ids that misses the furni id, a zero amount. importSingleFurni reads its own row back and reports each of those as a warning, where the import report already is, instead of leaving it to surface as "the import did not work" in the client. Finally, the catalog items table no longer falls back to router.refresh() — onRefresh is now required, so every mutation ends in a refresh of the caller's own data instead of a route re-render that threw away editor state and scroll position. useServerAction keeps its default, because 47 callers across the app depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that kept its own stale tree and three more router.refresh() calls; only CatalogIcon and LAYOUT_COLORS are still imported, so the rest is gone. Tests: the store now covers revisions, 304s, probe failures and error recovery; a jsdom test mounts a consumer and asserts the tree updates in place with no navigation; the old organize-imports e2e asserted nothing about the endpoints the code actually calls, and is replaced by one that asserts a cross-tab write lands in the mounted categories without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2384 lines
72 KiB
TypeScript
2384 lines
72 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,
|
|
Trash2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
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 { SearchInput } from "@/components/admin/search-input";
|
|
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 { refreshCatalogTree } from "@/features/catalog/client/use-live-catalog";
|
|
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
|
import {
|
|
discoverOffers,
|
|
OFFER_FILTERS,
|
|
type OfferFilter,
|
|
} from "@/features/catalog/domain/offer-discovery";
|
|
import { useServerAction } from "@/hooks/use-server-action";
|
|
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
|
import { cn } from "@/lib/utils";
|
|
import { AddItemFormFields } from "./add-item-form-fields";
|
|
import {
|
|
InteractionTypeCombobox,
|
|
MovePageCombobox,
|
|
ToggleField,
|
|
} from "./field-helpers";
|
|
import { FurniIcon } from "./furni-icon";
|
|
import { SongPicker } from "./song-picker";
|
|
import {
|
|
type BaseItemData,
|
|
type CatalogItemData,
|
|
DEFAULT_NEW_ITEM,
|
|
getPointsLabel,
|
|
type InlineEdit,
|
|
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 {
|
|
initialSearch?: string;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
/**
|
|
* Required, not optional: every mutation here has to end in a refresh of the
|
|
* caller's own data. The table used to fall back to `router.refresh()` when
|
|
* this was omitted, which re-rendered the whole route to redraw one table —
|
|
* and lost the editor state and scroll position that go with it.
|
|
*/
|
|
onRefresh: () => void;
|
|
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({
|
|
initialSearch = "",
|
|
onDirtyChange,
|
|
onRefresh,
|
|
items,
|
|
pageId,
|
|
pageLayout,
|
|
canEdit,
|
|
interactionTypes,
|
|
furniRevisionMap,
|
|
allPages,
|
|
}: CatalogItemsTableProps) {
|
|
const bulk = useTranslations("pages.admin.catalog.bulk");
|
|
const discovery = useTranslations("pages.admin.catalog.discovery");
|
|
const t = useTranslations(
|
|
"pages.admin.catalog.catalogItemsTable.catalogItemsTable",
|
|
);
|
|
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
|
CatalogItemData[] | null
|
|
>(null);
|
|
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(initialSearch);
|
|
const [filters, setFilters] = useState<Record<string, string>>({
|
|
offerFilter: "all",
|
|
clubOnlyFilter: "all",
|
|
haveOfferFilter: "all",
|
|
});
|
|
|
|
const { items: filteredItems, counts: filterCounts } = useMemo(
|
|
() => discoverOffers(items, searchQuery, filters),
|
|
[items, searchQuery, filters],
|
|
);
|
|
const hasDiscoveryFilter =
|
|
Boolean(searchQuery.trim()) ||
|
|
Object.values(filters).some((f) => f !== "all");
|
|
|
|
// ── 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() || Object.values(filters).some((f) => f !== "all"))
|
|
return filteredItems;
|
|
const orderMap = new Map(itemOrder.map((id, idx) => [id, idx]));
|
|
return filteredItems
|
|
.slice()
|
|
.sort((a, b) => (orderMap.get(a.id) ?? 0) - (orderMap.get(b.id) ?? 0));
|
|
}, [filteredItems, itemOrder, searchQuery, filters]);
|
|
|
|
// ── 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 allVisibleSelected =
|
|
filteredItems.length > 0 &&
|
|
filteredItems.every((item) => selected.has(item.id));
|
|
const someVisibleSelected = filteredItems.some((item) =>
|
|
selected.has(item.id),
|
|
);
|
|
const toggleAll = useCallback(() => {
|
|
setSelected((previous) => {
|
|
const next = new Set(previous);
|
|
const allSelected = filteredItems.every((item) => previous.has(item.id));
|
|
for (const item of filteredItems) {
|
|
if (allSelected) next.delete(item.id);
|
|
else next.add(item.id);
|
|
}
|
|
return next;
|
|
});
|
|
}, [filteredItems]);
|
|
function updateSearch(query: string) {
|
|
setSearchQuery(query);
|
|
setSelected(new Set());
|
|
}
|
|
function updateFilter(key: string, value: string) {
|
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
setSelected(new Set());
|
|
}
|
|
function resetDiscovery() {
|
|
setSearchQuery("");
|
|
setFilters({
|
|
offerFilter: "all",
|
|
clubOnlyFilter: "all",
|
|
haveOfferFilter: "all",
|
|
});
|
|
setSelected(new Set());
|
|
}
|
|
|
|
// ── 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 =
|
|
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
|
|
useEffect(() => {
|
|
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
|
|
setInlineEdits(
|
|
Object.fromEntries(
|
|
items.map((item) => [
|
|
item.id,
|
|
{
|
|
costCredits: item.costCredits,
|
|
costPoints: item.costPoints,
|
|
pointsType: item.pointsType,
|
|
amount: item.amount,
|
|
},
|
|
]),
|
|
),
|
|
);
|
|
setItemOrder(items.map((item) => item.id));
|
|
setBulkRefreshFrom(null);
|
|
}, [items, bulkRefreshFrom]);
|
|
useEffect(() => {
|
|
onDirtyChange?.(hasUnsavedChanges);
|
|
}, [hasUnsavedChanges, onDirtyChange]);
|
|
|
|
// 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);
|
|
onRefresh();
|
|
},
|
|
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 (savedCount > 0) {
|
|
onRefresh();
|
|
}
|
|
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());
|
|
onRefresh();
|
|
},
|
|
});
|
|
}
|
|
|
|
// ── 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);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── 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.",
|
|
onSuccess: () => onRefresh(),
|
|
});
|
|
}
|
|
|
|
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 });
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
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.",
|
|
onSuccess: () => onRefresh(),
|
|
});
|
|
}
|
|
|
|
// ── 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);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
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.",
|
|
onSuccess: () => onRefresh(),
|
|
},
|
|
);
|
|
}
|
|
|
|
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);
|
|
onRefresh();
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
// ── Add Item Form Fields (for "Add" dialog) ───────────────────
|
|
|
|
return (
|
|
<>
|
|
{confirmDialog}
|
|
{bulkRefreshFrom !== null && (
|
|
<p
|
|
role="status"
|
|
className="flex items-center gap-2 text-sm text-muted-foreground"
|
|
>
|
|
<Loader2 className="size-4 animate-spin" />
|
|
{bulk("refreshing")}
|
|
</p>
|
|
)}
|
|
<Card
|
|
inert={bulkRefreshFrom !== null}
|
|
aria-busy={bulkRefreshFrom !== null}
|
|
>
|
|
<CardHeader className="space-y-3">
|
|
<div className="flex flex-row items-center justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<CardTitle>
|
|
{t("itemsCount", { count: items.length })}
|
|
{hasDiscoveryFilter &&
|
|
filteredItems.length !== items.length && (
|
|
<span className="text-muted-foreground font-normal text-sm ml-2">
|
|
{discovery("shown", { count: filteredItems.length })}
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
{selected.size > 0 && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{discovery("selected", { count: selected.size })}
|
|
</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 && (
|
|
<>
|
|
<BulkOfferEditor
|
|
ids={[...selected]}
|
|
allPages={allPages}
|
|
disabled={
|
|
hasUnsavedChanges ||
|
|
isPending ||
|
|
savingItemId !== null
|
|
}
|
|
onApplied={() => {
|
|
setBulkRefreshFrom(items);
|
|
setSelected(new Set());
|
|
onRefresh();
|
|
}}
|
|
/>
|
|
<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={() => {
|
|
onRefresh();
|
|
void refreshCatalogTree();
|
|
}}
|
|
/>
|
|
<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>
|
|
)}
|
|
|
|
{/* Offer discovery */}
|
|
{items.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="min-w-0 flex-1">
|
|
<SearchInput
|
|
placeholder={discovery("searchPlaceholder")}
|
|
value={searchQuery}
|
|
onChange={updateSearch}
|
|
onClear={() => updateSearch("")}
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={filters.offerFilter}
|
|
onValueChange={(v) => updateFilter("offerFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("filterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.offerFilter}`)} (
|
|
{filterCounts[filters.offerFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{OFFER_FILTERS.slice(0, 5).map((filter) => (
|
|
<SelectItem key={filter} value={filter}>
|
|
{discovery(`filters.${filter}`)} ({filterCounts[filter]}
|
|
)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.clubOnlyFilter}
|
|
onValueChange={(v) => updateFilter("clubOnlyFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("clubOnlyFilterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.clubOnlyFilter}`)} (
|
|
{filterCounts[filters.clubOnlyFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
{discovery("filters.all")}
|
|
</SelectItem>
|
|
<SelectItem value="club_only_yes">
|
|
{discovery("filters.club_only_yes")}
|
|
</SelectItem>
|
|
<SelectItem value="club_only_no">
|
|
{discovery("filters.club_only_no")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={filters.haveOfferFilter}
|
|
onValueChange={(v) => updateFilter("haveOfferFilter", v)}
|
|
>
|
|
<SelectTrigger
|
|
aria-label={discovery("haveOfferFilterLabel")}
|
|
className="w-full sm:w-52"
|
|
>
|
|
<SelectValue>
|
|
{discovery(`filters.${filters.haveOfferFilter}`)} (
|
|
{filterCounts[filters.haveOfferFilter as OfferFilter]})
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">
|
|
{discovery("filters.all")}
|
|
</SelectItem>
|
|
<SelectItem value="have_offer_yes">
|
|
{discovery("filters.have_offer_yes")}
|
|
</SelectItem>
|
|
<SelectItem value="have_offer_no">
|
|
{discovery("filters.have_offer_no")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{hasDiscoveryFilter && (
|
|
<Button variant="ghost" size="sm" onClick={resetDiscovery}>
|
|
{discovery("reset")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{discovery("results", {
|
|
count: filteredItems.length,
|
|
total: items.length,
|
|
})}
|
|
{canEdit && ` ${discovery("selectionHint")}`}
|
|
</p>
|
|
</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">
|
|
{discovery("noResults")}
|
|
</p>
|
|
) : viewMode === "grid" ? (
|
|
<ItemsShopPreview
|
|
items={orderedFilteredItems as CatalogItemData[]}
|
|
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={allVisibleSelected}
|
|
indeterminate={
|
|
someVisibleSelected && !allVisibleSelected
|
|
}
|
|
onCheckedChange={toggleAll}
|
|
aria-label={discovery("selectVisible")}
|
|
/>
|
|
</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 || hasDiscoveryFilter;
|
|
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>
|
|
</>
|
|
);
|
|
}
|