feat: add users management page with search, editing, and inventory control
Some checks failed
Deploy to Production / test (push) Failing after 30s

Implements comprehensive user management interface for admin panel:
- Search and filter users by username, class, and active status
- Sort by username, level, balance, or XP with pagination
- View and edit user details (balance, XP, level, class, daily streak, active status)
- Manage user inventories (add/remove items with quantities)
- Debounced search input (300ms delay)
- Responsive design (mobile full-screen, desktop slide-in panel)
- Draft state management with unsaved changes tracking
- Keyboard shortcuts (Escape to close detail panel)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
syntaxbullet
2026-02-14 13:15:37 +01:00
parent f0bfaecb0b
commit 87d5aa259c
3 changed files with 1408 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import Layout, { type Page } from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import Settings from "./pages/Settings";
import Users from "./pages/Users";
import PlaceholderPage from "./pages/PlaceholderPage";
const placeholders: Record<string, { title: string; description: string }> = {
@@ -75,6 +76,8 @@ export default function App() {
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
{page === "dashboard" ? (
<Dashboard />
) : page === "users" ? (
<Users />
) : page === "settings" ? (
<Settings />
) : (

343
panel/src/lib/useUsers.ts Normal file
View File

@@ -0,0 +1,343 @@
import { useCallback, useEffect, useState } from "react";
import { get, put, post, del } from "./api";
export interface User {
id: string;
username: string;
classId: string | null;
isActive: boolean;
balance: string;
xp: string;
level: number;
dailyStreak: number;
settings: Record<string, unknown>;
createdAt: string;
updatedAt: string;
class?: Class;
}
export interface Class {
id: string;
name: string;
description: string;
icon: string;
}
export interface Item {
id: number;
name: string;
description: string;
rarity: string;
sellPrice: string;
buyPrice: string;
}
export interface InventoryEntry {
userId: string;
itemId: number;
quantity: string;
item?: Item;
}
export interface UserFilters {
search: string;
classId: string | null;
isActive: boolean | null;
sortBy: "username" | "level" | "balance" | "xp";
sortOrder: "asc" | "desc";
}
export function useUsers() {
// User list state
const [users, setUsers] = useState<User[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(50);
// Filters
const [filters, setFiltersState] = useState<UserFilters>({
search: "",
classId: null,
isActive: null,
sortBy: "balance",
sortOrder: "desc",
});
// Detail panel state
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userDraft, setUserDraft] = useState<Partial<User> | null>(null);
const [inventoryDraft, setInventoryDraft] = useState<InventoryEntry[]>([]);
// Reference data
const [classes, setClasses] = useState<Class[]>([]);
const [items, setItems] = useState<Item[]>([]);
// UI state
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch users with filters and pagination
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.classId) params.set("classId", filters.classId);
if (filters.isActive !== null) params.set("isActive", String(filters.isActive));
params.set("sortBy", filters.sortBy);
params.set("sortOrder", filters.sortOrder);
params.set("limit", String(limit));
params.set("offset", String((currentPage - 1) * limit));
const data = await get<{ users: User[]; total: number }>(
`/api/users?${params.toString()}`
);
setUsers(data.users);
setTotal(data.total);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load users");
} finally {
setLoading(false);
}
}, [filters, currentPage, limit]);
// Fetch single user by ID
const fetchUserById = useCallback(async (id: string) => {
try {
const user = await get<User>(`/api/users/${id}`);
return user;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load user");
return null;
}
}, []);
// Fetch classes for filter dropdown
const fetchClasses = useCallback(async () => {
try {
const data = await get<{ classes: Class[] }>("/api/classes");
setClasses(data.classes || []);
} catch (e) {
console.error("Failed to load classes:", e);
setClasses([]);
}
}, []);
// Fetch items for inventory management
const fetchItems = useCallback(async () => {
try {
const data = await get<{ items: Item[]; total: number }>("/api/items");
setItems(data.items || []);
} catch (e) {
console.error("Failed to load items:", e);
setItems([]);
}
}, []);
// Fetch user inventory
const fetchInventory = useCallback(async (userId: string) => {
try {
const data = await get<{ inventory: InventoryEntry[] }>(
`/api/users/${userId}/inventory`
);
setInventoryDraft(data.inventory);
return data.inventory;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load inventory");
return [];
}
}, []);
// Update user
const updateUser = useCallback(async (id: string, data: Partial<User>) => {
try {
setSaving(true);
setError(null);
const result = await put<{ success: boolean; user: User }>(
`/api/users/${id}`,
data
);
return result.user;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to update user");
return null;
} finally {
setSaving(false);
}
}, []);
// Add item to inventory
const addInventoryItem = useCallback(
async (userId: string, itemId: number, quantity: string) => {
try {
await post(`/api/users/${userId}/inventory`, { itemId, quantity });
await fetchInventory(userId);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to add item");
return false;
}
},
[fetchInventory]
);
// Remove item from inventory
const removeInventoryItem = useCallback(
async (userId: string, itemId: number) => {
try {
await del(`/api/users/${userId}/inventory/${itemId}`);
await fetchInventory(userId);
return true;
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to remove item");
return false;
}
},
[fetchInventory]
);
// Set filters and reset to page 1
const setFilters = useCallback((newFilters: Partial<UserFilters>) => {
setFiltersState((prev) => ({ ...prev, ...newFilters }));
setCurrentPage(1);
}, []);
// Debounced search setter
const setSearchDebounced = useCallback(
(() => {
let timeoutId: NodeJS.Timeout;
return (search: string) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setFilters({ search });
}, 300);
};
})(),
[setFilters]
);
// Navigate to page
const setPage = useCallback((page: number) => {
setCurrentPage(page);
}, []);
// Select user and open detail panel
const selectUser = useCallback(
async (user: User) => {
setSelectedUser(user);
// Fetch fresh data
const freshUser = await fetchUserById(user.id);
if (freshUser) {
setSelectedUser(freshUser);
setUserDraft(structuredClone(freshUser));
await fetchInventory(freshUser.id);
}
},
[fetchUserById, fetchInventory]
);
// Close detail panel
const closeDetail = useCallback(() => {
setSelectedUser(null);
setUserDraft(null);
setInventoryDraft([]);
}, []);
// Update draft field
const updateDraft = useCallback((field: keyof User, value: unknown) => {
setUserDraft((prev) => {
if (!prev) return null;
return { ...prev, [field]: value };
});
}, []);
// Save draft changes
const saveDraft = useCallback(async () => {
if (!selectedUser || !userDraft) return false;
const updated = await updateUser(selectedUser.id, userDraft);
if (updated) {
setSelectedUser(updated);
setUserDraft(structuredClone(updated));
// Refresh the list to show updated data
await fetchUsers();
return true;
}
return false;
}, [selectedUser, userDraft, updateUser, fetchUsers]);
// Discard draft changes
const discardDraft = useCallback(() => {
if (selectedUser) {
setUserDraft(structuredClone(selectedUser));
}
}, [selectedUser]);
// Check if draft has changes
const isDirty = useCallback(() => {
if (!selectedUser || !userDraft) return false;
return JSON.stringify(selectedUser) !== JSON.stringify(userDraft);
}, [selectedUser, userDraft]);
// Initial load
useEffect(() => {
fetchUsers();
fetchClasses();
fetchItems();
}, [fetchUsers, fetchClasses, fetchItems]);
return {
// User list
users,
total,
currentPage,
limit,
setLimit,
// Filters
filters,
setFilters,
setSearchDebounced,
// Pagination
setPage,
// Detail panel
selectedUser,
selectUser,
closeDetail,
// Editing
userDraft,
updateDraft,
saveDraft,
discardDraft,
isDirty: isDirty(),
// Inventory
inventoryDraft,
addInventoryItem,
removeInventoryItem,
// Reference data
classes,
items,
// UI state
loading,
saving,
error,
// Actions
refetch: fetchUsers,
};
}

1062
panel/src/pages/Users.tsx Normal file

File diff suppressed because it is too large Load Diff