From e1cf72c1955396a79a93e81d040f4ae6e97036b8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 10 Jun 2025 17:12:33 +0000 Subject: [PATCH 1/4] Create users API service utilities cgen-606b41a7e7214fbe89fbed5a9d25ad06 --- src/crm/components/UserEditModal.tsx | 293 +++++++++++++++++++++ src/crm/pages/Customers.tsx | 370 ++++++++++++++++++++++++++- src/crm/services/usersApi.ts | 197 ++++++++++++++ 3 files changed, 853 insertions(+), 7 deletions(-) create mode 100644 src/crm/components/UserEditModal.tsx create mode 100644 src/crm/services/usersApi.ts diff --git a/src/crm/components/UserEditModal.tsx b/src/crm/components/UserEditModal.tsx new file mode 100644 index 0000000..4db76f4 --- /dev/null +++ b/src/crm/components/UserEditModal.tsx @@ -0,0 +1,293 @@ +import * as React from "react"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import TextField from "@mui/material/TextField"; +import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; +import Alert from "@mui/material/Alert"; +import { User, UpdateUserRequest, UsersApiService } from "../services/usersApi"; + +interface UserEditModalProps { + open: boolean; + user: User | null; + onClose: () => void; + onUserUpdated: () => void; +} + +export default function UserEditModal({ + open, + user, + onClose, + onUserUpdated, +}: UserEditModalProps) { + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [formData, setFormData] = React.useState({}); + + React.useEffect(() => { + if (user) { + setFormData({ + name: { + title: user.name.title, + first: user.name.first, + last: user.name.last, + }, + email: user.email, + location: { + street: { + number: user.location.street.number, + name: user.location.street.name, + }, + city: user.location.city, + state: user.location.state, + country: user.location.country, + postcode: user.location.postcode, + }, + phone: user.phone, + cell: user.cell, + gender: user.gender, + }); + } + setError(null); + }, [user, open]); + + const handleInputChange = (field: string, value: string) => { + setFormData((prev) => { + const keys = field.split("."); + const result = { ...prev }; + let current: any = result; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } + + current[keys[keys.length - 1]] = value; + return result; + }); + }; + + const getNestedValue = (obj: any, path: string): string => { + return path.split(".").reduce((current, key) => current?.[key] || "", obj); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!user) return; + + setLoading(true); + setError(null); + + try { + await UsersApiService.updateUser(user.login.uuid, formData); + onUserUpdated(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update user"); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + if (!loading) { + onClose(); + } + }; + + return ( + + Edit User + + {error && ( + + {error} + + )} + + + + + Title + + + + + + handleInputChange("name.first", e.target.value)} + /> + + + + handleInputChange("name.last", e.target.value)} + /> + + + + handleInputChange("email", e.target.value)} + /> + + + + + Gender + + + + + + handleInputChange("phone", e.target.value)} + /> + + + + handleInputChange("cell", e.target.value)} + /> + + + + + handleInputChange("location.street.number", e.target.value) + } + /> + + + + + handleInputChange("location.street.name", e.target.value) + } + /> + + + + + handleInputChange("location.city", e.target.value) + } + /> + + + + + handleInputChange("location.state", e.target.value) + } + /> + + + + + handleInputChange("location.postcode", e.target.value) + } + /> + + + + + handleInputChange("location.country", e.target.value) + } + /> + + + + + + + + + + ); +} diff --git a/src/crm/pages/Customers.tsx b/src/crm/pages/Customers.tsx index bd63a59..3b734fe 100644 --- a/src/crm/pages/Customers.tsx +++ b/src/crm/pages/Customers.tsx @@ -1,17 +1,373 @@ import * as React from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import Card from "@mui/material/Card"; +import CardContent from "@mui/material/CardContent"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; +import SearchIcon from "@mui/icons-material/Search"; +import Avatar from "@mui/material/Avatar"; +import IconButton from "@mui/material/IconButton"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import Button from "@mui/material/Button"; +import Alert from "@mui/material/Alert"; +import Snackbar from "@mui/material/Snackbar"; +import { + DataGrid, + GridColDef, + GridRowsProp, + GridToolbar, +} from "@mui/x-data-grid"; +import { User, UsersApiService } from "../services/usersApi"; +import UserEditModal from "../components/UserEditModal"; export default function Customers() { + const [users, setUsers] = React.useState([]); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [searchQuery, setSearchQuery] = React.useState(""); + const [totalUsers, setTotalUsers] = React.useState(0); + const [page, setPage] = React.useState(0); + const [pageSize, setPageSize] = React.useState(25); + const [editModalOpen, setEditModalOpen] = React.useState(false); + const [selectedUser, setSelectedUser] = React.useState(null); + const [snackbar, setSnackbar] = React.useState<{ + open: boolean; + message: string; + severity: "success" | "error"; + }>({ open: false, message: "", severity: "success" }); + + const fetchUsers = React.useCallback(async () => { + setLoading(true); + setError(null); + + try { + const response = await UsersApiService.getUsers({ + page: page + 1, // API uses 1-based pagination + perPage: pageSize, + search: searchQuery || undefined, + }); + + setUsers(response.data); + setTotalUsers(response.total); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch users"; + setError(errorMessage); + setSnackbar({ + open: true, + message: errorMessage, + severity: "error", + }); + } finally { + setLoading(false); + } + }, [page, pageSize, searchQuery]); + + React.useEffect(() => { + fetchUsers(); + }, [fetchUsers]); + + const handleSearch = React.useMemo(() => { + const timeoutId = React.useRef(); + + return (value: string) => { + if (timeoutId.current) { + clearTimeout(timeoutId.current); + } + + timeoutId.current = setTimeout(() => { + setSearchQuery(value); + setPage(0); // Reset to first page when searching + }, 500); + }; + }, []); + + const handleEditUser = (user: User) => { + setSelectedUser(user); + setEditModalOpen(true); + }; + + const handleDeleteUser = async (user: User) => { + if ( + !window.confirm( + `Are you sure you want to delete ${user.name.first} ${user.name.last}?`, + ) + ) { + return; + } + + try { + await UsersApiService.deleteUser(user.login.uuid); + setSnackbar({ + open: true, + message: "User deleted successfully", + severity: "success", + }); + fetchUsers(); // Refresh the list + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to delete user"; + setSnackbar({ + open: true, + message: errorMessage, + severity: "error", + }); + } + }; + + const handleUserUpdated = () => { + setSnackbar({ + open: true, + message: "User updated successfully", + severity: "success", + }); + fetchUsers(); // Refresh the list + }; + + const handleCloseSnackbar = () => { + setSnackbar((prev) => ({ ...prev, open: false })); + }; + + const formatFullName = (user: User): string => { + return `${user.name.title} ${user.name.first} ${user.name.last}`; + }; + + const formatAddress = (user: User): string => { + const { location } = user; + return `${location.street.number} ${location.street.name}, ${location.city}, ${location.state} ${location.postcode}`; + }; + + const formatDate = (dateString: string): string => { + return new Date(dateString).toLocaleDateString(); + }; + + const columns: GridColDef[] = [ + { + field: "avatar", + headerName: "", + width: 60, + sortable: false, + filterable: false, + renderCell: (params) => ( + + {params.row.name.first[0]} + {params.row.name.last[0]} + + ), + }, + { + field: "fullName", + headerName: "Name", + flex: 1, + minWidth: 200, + valueGetter: (value, row) => formatFullName(row), + }, + { + field: "email", + headerName: "Email", + flex: 1, + minWidth: 200, + }, + { + field: "phone", + headerName: "Phone", + flex: 0.8, + minWidth: 150, + }, + { + field: "location", + headerName: "Location", + flex: 1.5, + minWidth: 250, + valueGetter: (value, row) => + `${row.location.city}, ${row.location.country}`, + }, + { + field: "address", + headerName: "Address", + flex: 2, + minWidth: 300, + valueGetter: (value, row) => formatAddress(row), + }, + { + field: "age", + headerName: "Age", + width: 80, + valueGetter: (value, row) => row.dob.age, + }, + { + field: "registered", + headerName: "Registered", + width: 120, + valueGetter: (value, row) => formatDate(row.registered.date), + }, + { + field: "actions", + headerName: "Actions", + width: 120, + sortable: false, + filterable: false, + renderCell: (params) => ( + + handleEditUser(params.row)} + color="primary" + title="Edit user" + > + + + handleDeleteUser(params.row)} + color="error" + title="Delete user" + > + + + + ), + }, + ]; + + const rows: GridRowsProp = users.map((user) => ({ + id: user.login.uuid, + ...user, + })); + return ( - - Customers Page - - - This is the customers management page where you can view and manage your - customer data. - + + + Customers + + + + + + + + + + ), + }} + onChange={(e) => handleSearch(e.target.value)} + sx={{ mb: 2 }} + /> + + {error && ( + + {error} + + )} + + + + + + + + { + setEditModalOpen(false); + setSelectedUser(null); + }} + onUserUpdated={handleUserUpdated} + /> + + + + {snackbar.message} + + ); } diff --git a/src/crm/services/usersApi.ts b/src/crm/services/usersApi.ts new file mode 100644 index 0000000..1e0dc98 --- /dev/null +++ b/src/crm/services/usersApi.ts @@ -0,0 +1,197 @@ +const API_BASE_URL = "https://user-api.builder-io.workers.dev/api"; + +export interface User { + login: { + uuid: string; + username: string; + password: string; + }; + name: { + title: string; + first: string; + last: string; + }; + gender: string; + location: { + street: { + number: number; + name: string; + }; + city: string; + state: string; + country: string; + postcode: string; + coordinates: { + latitude: number; + longitude: number; + }; + timezone: { + offset: string; + description: string; + }; + }; + email: string; + dob: { + date: string; + age: number; + }; + registered: { + date: string; + age: number; + }; + phone: string; + cell: string; + picture: { + large: string; + medium: string; + thumbnail: string; + }; + nat: string; +} + +export interface UsersApiResponse { + page: number; + perPage: number; + total: number; + span: string; + effectivePage: number; + data: User[]; +} + +export interface UsersApiParams { + page?: number; + perPage?: number; + search?: string; + sortBy?: string; + span?: string; +} + +export interface CreateUserRequest { + email: string; + login: { + username: string; + password?: string; + }; + name: { + first: string; + last: string; + title?: string; + }; + gender?: string; + location?: { + street?: { + number?: number; + name?: string; + }; + city?: string; + state?: string; + country?: string; + postcode?: string; + }; +} + +export interface UpdateUserRequest { + name?: { + first?: string; + last?: string; + title?: string; + }; + email?: string; + location?: { + street?: { + number?: number; + name?: string; + }; + city?: string; + state?: string; + country?: string; + postcode?: string; + }; + phone?: string; + cell?: string; + gender?: string; +} + +export class UsersApiService { + static async getUsers( + params: UsersApiParams = {}, + ): Promise { + const searchParams = new URLSearchParams(); + + if (params.page) searchParams.append("page", params.page.toString()); + if (params.perPage) + searchParams.append("perPage", params.perPage.toString()); + if (params.search) searchParams.append("search", params.search); + if (params.sortBy) searchParams.append("sortBy", params.sortBy); + if (params.span) searchParams.append("span", params.span); + + const response = await fetch(`${API_BASE_URL}/users?${searchParams}`); + + if (!response.ok) { + throw new Error(`Failed to fetch users: ${response.statusText}`); + } + + return response.json(); + } + + static async getUser(id: string): Promise { + const response = await fetch(`${API_BASE_URL}/users/${id}`); + + if (!response.ok) { + throw new Error(`Failed to fetch user: ${response.statusText}`); + } + + return response.json(); + } + + static async createUser( + user: CreateUserRequest, + ): Promise<{ success: boolean; uuid: string; message: string }> { + const response = await fetch(`${API_BASE_URL}/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(user), + }); + + if (!response.ok) { + throw new Error(`Failed to create user: ${response.statusText}`); + } + + return response.json(); + } + + static async updateUser( + id: string, + updates: UpdateUserRequest, + ): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${API_BASE_URL}/users/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updates), + }); + + if (!response.ok) { + throw new Error(`Failed to update user: ${response.statusText}`); + } + + return response.json(); + } + + static async deleteUser( + id: string, + ): Promise<{ success: boolean; message: string }> { + const response = await fetch(`${API_BASE_URL}/users/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error(`Failed to delete user: ${response.statusText}`); + } + + return response.json(); + } +} From a1f231f76cc48f90b5f60c629c59925afdf65a5d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 10 Jun 2025 17:14:02 +0000 Subject: [PATCH 2/4] Fix hooks issue by moving useRef to top level and restructuring handleSearch cgen-acc15108c2d94a5b908050731ae624ee --- src/crm/pages/Customers.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/crm/pages/Customers.tsx b/src/crm/pages/Customers.tsx index 3b734fe..3023af5 100644 --- a/src/crm/pages/Customers.tsx +++ b/src/crm/pages/Customers.tsx @@ -39,6 +39,9 @@ export default function Customers() { severity: "success" | "error"; }>({ open: false, message: "", severity: "success" }); + // Search debounce timeout ref - must be at top level + const searchTimeoutRef = React.useRef(); + const fetchUsers = React.useCallback(async () => { setLoading(true); setError(null); @@ -70,19 +73,15 @@ export default function Customers() { fetchUsers(); }, [fetchUsers]); - const handleSearch = React.useMemo(() => { - const timeoutId = React.useRef(); - - return (value: string) => { - if (timeoutId.current) { - clearTimeout(timeoutId.current); - } + const handleSearch = React.useCallback((value: string) => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } - timeoutId.current = setTimeout(() => { - setSearchQuery(value); - setPage(0); // Reset to first page when searching - }, 500); - }; + searchTimeoutRef.current = setTimeout(() => { + setSearchQuery(value); + setPage(0); // Reset to first page when searching + }, 500); }, []); const handleEditUser = (user: User) => { From e38d8b07e493e2bf699d69d3d29167b24924a7a8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 10 Jun 2025 17:14:11 +0000 Subject: [PATCH 3/4] Add cleanup effect for search timeout cgen-78270c4186dd499cb73887aa2e182f89 --- src/crm/pages/Customers.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/crm/pages/Customers.tsx b/src/crm/pages/Customers.tsx index 3023af5..2dd3ba7 100644 --- a/src/crm/pages/Customers.tsx +++ b/src/crm/pages/Customers.tsx @@ -73,6 +73,15 @@ export default function Customers() { fetchUsers(); }, [fetchUsers]); + // Cleanup search timeout on unmount + React.useEffect(() => { + return () => { + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + }; + }, []); + const handleSearch = React.useCallback((value: string) => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); From 0aad06ba256e104f2150adf7916f8a76f6b53917 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Tue, 10 Jun 2025 17:16:47 +0000 Subject: [PATCH 4/4] Fix vertical alignment for avatar and action buttons in DataGrid cgen-1416e601d4e846928f0a420631eb0de8 --- src/crm/pages/Customers.tsx | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/src/crm/pages/Customers.tsx b/src/crm/pages/Customers.tsx index 2dd3ba7..5ddcd64 100644 --- a/src/crm/pages/Customers.tsx +++ b/src/crm/pages/Customers.tsx @@ -159,15 +159,26 @@ export default function Customers() { width: 60, sortable: false, filterable: false, + headerAlign: "center", + align: "center", renderCell: (params) => ( - - {params.row.name.first[0]} - {params.row.name.last[0]} - + + {params.row.name.first[0]} + {params.row.name.last[0]} + + ), }, { @@ -222,8 +233,18 @@ export default function Customers() { width: 120, sortable: false, filterable: false, + headerAlign: "center", + align: "center", renderCell: (params) => ( - + handleEditUser(params.row)}