feat: add users management page with search, editing, and inventory control
Some checks failed
Deploy to Production / test (push) Failing after 30s
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:
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
|
|||||||
import Layout, { type Page } from "./components/Layout";
|
import Layout, { type Page } from "./components/Layout";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
import Users from "./pages/Users";
|
||||||
import PlaceholderPage from "./pages/PlaceholderPage";
|
import PlaceholderPage from "./pages/PlaceholderPage";
|
||||||
|
|
||||||
const placeholders: Record<string, { title: string; description: string }> = {
|
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}>
|
<Layout user={user} logout={logout} currentPage={page} onNavigate={setPage}>
|
||||||
{page === "dashboard" ? (
|
{page === "dashboard" ? (
|
||||||
<Dashboard />
|
<Dashboard />
|
||||||
|
) : page === "users" ? (
|
||||||
|
<Users />
|
||||||
) : page === "settings" ? (
|
) : page === "settings" ? (
|
||||||
<Settings />
|
<Settings />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
343
panel/src/lib/useUsers.ts
Normal file
343
panel/src/lib/useUsers.ts
Normal 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
1062
panel/src/pages/Users.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user