diff --git a/src/crm/components/CreateCustomerDialog.tsx b/src/crm/components/CreateCustomerDialog.tsx new file mode 100644 index 0000000..a378244 --- /dev/null +++ b/src/crm/components/CreateCustomerDialog.tsx @@ -0,0 +1,345 @@ +import * as React from "react"; +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 Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +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 CircularProgress from "@mui/material/CircularProgress"; +import Alert from "@mui/material/Alert"; +import type { CreateCustomerRequest } from "../types/customer"; + +interface CreateCustomerDialogProps { + open: boolean; + onClose: () => void; + onCreate: (data: CreateCustomerRequest) => Promise; +} + +const initialFormData: CreateCustomerRequest = { + email: "", + login: { + username: "", + password: "", + }, + name: { + first: "", + last: "", + title: "Mr", + }, + gender: "male", + location: { + street: { + number: 0, + name: "", + }, + city: "", + state: "", + country: "", + postcode: "", + }, + phone: "", + cell: "", +}; + +export default function CreateCustomerDialog({ + open, + onClose, + onCreate, +}: CreateCustomerDialogProps) { + const [formData, setFormData] = + React.useState(initialFormData); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (open) { + setFormData(initialFormData); + setError(null); + } + }, [open]); + + const handleInputChange = (field: string, value: any) => { + setFormData((prev) => { + const keys = field.split("."); + const updated = { ...prev }; + let current: any = updated; + + 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 updated; + }); + }; + + const validateForm = (): boolean => { + if ( + !formData.email || + !formData.login.username || + !formData.name.first || + !formData.name.last + ) { + setError( + "Please fill in all required fields (Email, Username, First Name, Last Name)", + ); + return false; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email)) { + setError("Please enter a valid email address"); + return false; + } + + return true; + }; + + const handleCreate = async () => { + if (!validateForm()) return; + + try { + setLoading(true); + setError(null); + await onCreate(formData); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to create customer", + ); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + if (!loading) { + onClose(); + } + }; + + return ( + + + Create New Customer + + + + {error && ( + + {error} + + )} + + + + handleInputChange("email", e.target.value)} + required + /> + + + + + handleInputChange("login.username", e.target.value) + } + required + /> + + + + + Title + + + + + + handleInputChange("name.first", e.target.value)} + required + /> + + + + handleInputChange("name.last", e.target.value)} + required + /> + + + + + Gender + + + + + + + handleInputChange("login.password", e.target.value) + } + helperText="Leave empty for auto-generated password" + /> + + + + handleInputChange("phone", e.target.value)} + /> + + + + handleInputChange("cell", e.target.value)} + /> + + + + + Address (Optional) + + + + + + handleInputChange( + "location.street.number", + parseInt(e.target.value) || 0, + ) + } + /> + + + + + handleInputChange("location.street.name", e.target.value) + } + /> + + + + + handleInputChange("location.city", e.target.value) + } + /> + + + + + handleInputChange("location.state", e.target.value) + } + /> + + + + + handleInputChange("location.country", e.target.value) + } + /> + + + + + handleInputChange("location.postcode", e.target.value) + } + /> + + + + + + + + + + ); +} diff --git a/src/crm/components/CustomerDetailsDialog.tsx b/src/crm/components/CustomerDetailsDialog.tsx new file mode 100644 index 0000000..07826f1 --- /dev/null +++ b/src/crm/components/CustomerDetailsDialog.tsx @@ -0,0 +1,330 @@ +import * as React from "react"; +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 Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Grid from "@mui/material/Grid"; +import Avatar from "@mui/material/Avatar"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +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 CircularProgress from "@mui/material/CircularProgress"; +import Alert from "@mui/material/Alert"; +import type { Customer, UpdateCustomerRequest } from "../types/customer"; + +interface CustomerDetailsDialogProps { + open: boolean; + customer: Customer | null; + onClose: () => void; + onSave: (id: string, data: UpdateCustomerRequest) => Promise; +} + +export default function CustomerDetailsDialog({ + open, + customer, + onClose, + onSave, +}: CustomerDetailsDialogProps) { + const [formData, setFormData] = React.useState({}); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + if (customer) { + setFormData({ + name: { + title: customer.name.title || "", + first: customer.name.first || "", + last: customer.name.last || "", + }, + email: customer.email || "", + phone: customer.phone || "", + cell: customer.cell || "", + gender: customer.gender || "", + location: { + street: { + number: customer.location.street?.number || 0, + name: customer.location.street?.name || "", + }, + city: customer.location.city || "", + state: customer.location.state || "", + country: customer.location.country || "", + postcode: customer.location.postcode || "", + }, + }); + } + }, [customer]); + + const handleInputChange = (field: string, value: any) => { + setFormData((prev) => { + const keys = field.split("."); + const updated = { ...prev }; + let current: any = updated; + + 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 updated; + }); + }; + + const handleSave = async () => { + if (!customer) return; + + try { + setLoading(true); + setError(null); + await onSave(customer.login.uuid, formData); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save customer"); + } finally { + setLoading(false); + } + }; + + if (!customer) return null; + + return ( + + + + + {customer.name.first[0]} + {customer.name.last[0]} + + + + {customer.name.first} {customer.name.last} + + + Customer ID: {customer.login.uuid} + + + + + + + {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)} + /> + + + + + Address + + + + + + handleInputChange( + "location.street.number", + parseInt(e.target.value), + ) + } + /> + + + + + handleInputChange("location.street.name", e.target.value) + } + /> + + + + + handleInputChange("location.city", e.target.value) + } + /> + + + + + handleInputChange("location.state", e.target.value) + } + /> + + + + + handleInputChange("location.country", e.target.value) + } + /> + + + + + handleInputChange("location.postcode", e.target.value) + } + /> + + + + + + + Registration Date + + + {new Date(customer.registered.date).toLocaleDateString()} + + + + + Date of Birth + + + {new Date(customer.dob.date).toLocaleDateString()} (Age:{" "} + {customer.dob.age}) + + + + + Nationality + + {customer.nat} + + + + + + + + + + + + ); +} diff --git a/src/crm/hooks/useCustomers.ts b/src/crm/hooks/useCustomers.ts new file mode 100644 index 0000000..b0fc1ec --- /dev/null +++ b/src/crm/hooks/useCustomers.ts @@ -0,0 +1,180 @@ +import { useState, useEffect, useCallback } from "react"; +import type { + Customer, + CustomersApiResponse, + CreateCustomerRequest, + UpdateCustomerRequest, +} from "../types/customer"; + +const API_BASE_URL = "https://user-api.builder-io.workers.dev/api"; + +interface UseCustomersOptions { + page?: number; + perPage?: number; + search?: string; + sortBy?: string; +} + +interface UseCustomersReturn { + customers: Customer[]; + loading: boolean; + error: string | null; + total: number; + page: number; + perPage: number; + refetch: () => void; + createCustomer: (data: CreateCustomerRequest) => Promise; + updateCustomer: (id: string, data: UpdateCustomerRequest) => Promise; + deleteCustomer: (id: string) => Promise; + getCustomer: (id: string) => Promise; +} + +export function useCustomers( + options: UseCustomersOptions = {}, +): UseCustomersReturn { + const [customers, setCustomers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(options.page || 1); + const [perPage, setPerPage] = useState(options.perPage || 20); + + const fetchCustomers = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + page: page.toString(), + perPage: perPage.toString(), + ...(options.search && { search: options.search }), + ...(options.sortBy && { sortBy: options.sortBy }), + }); + + const response = await fetch(`${API_BASE_URL}/users?${params}`); + + if (!response.ok) { + throw new Error(`Failed to fetch customers: ${response.status}`); + } + + const data: CustomersApiResponse = await response.json(); + setCustomers(data.data); + setTotal(data.total); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + setCustomers([]); + } finally { + setLoading(false); + } + }, [page, perPage, options.search, options.sortBy]); + + useEffect(() => { + fetchCustomers(); + }, [fetchCustomers]); + + const createCustomer = useCallback( + async (data: CreateCustomerRequest) => { + try { + const response = await fetch(`${API_BASE_URL}/users`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create customer"); + } + + await fetchCustomers(); // Refresh the list + } catch (err) { + throw new Error( + err instanceof Error ? err.message : "Failed to create customer", + ); + } + }, + [fetchCustomers], + ); + + const updateCustomer = useCallback( + async (id: string, data: UpdateCustomerRequest) => { + try { + const response = await fetch(`${API_BASE_URL}/users/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to update customer"); + } + + await fetchCustomers(); // Refresh the list + } catch (err) { + throw new Error( + err instanceof Error ? err.message : "Failed to update customer", + ); + } + }, + [fetchCustomers], + ); + + const deleteCustomer = useCallback( + async (id: string) => { + try { + const response = await fetch(`${API_BASE_URL}/users/${id}`, { + method: "DELETE", + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to delete customer"); + } + + await fetchCustomers(); // Refresh the list + } catch (err) { + throw new Error( + err instanceof Error ? err.message : "Failed to delete customer", + ); + } + }, + [fetchCustomers], + ); + + const getCustomer = useCallback( + async (id: string): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/users/${id}`); + + if (!response.ok) { + throw new Error(`Failed to fetch customer: ${response.status}`); + } + + return await response.json(); + } catch (err) { + console.error("Error fetching customer:", err); + return null; + } + }, + [], + ); + + return { + customers, + loading, + error, + total, + page, + perPage, + refetch: fetchCustomers, + createCustomer, + updateCustomer, + deleteCustomer, + getCustomer, + }; +} diff --git a/src/crm/pages/Customers.tsx b/src/crm/pages/Customers.tsx index bd63a59..ce8897b 100644 --- a/src/crm/pages/Customers.tsx +++ b/src/crm/pages/Customers.tsx @@ -1,17 +1,423 @@ import * as React from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import InputAdornment from "@mui/material/InputAdornment"; +import IconButton from "@mui/material/IconButton"; +import Alert from "@mui/material/Alert"; +import Snackbar from "@mui/material/Snackbar"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import Chip from "@mui/material/Chip"; +import Avatar from "@mui/material/Avatar"; +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 Stack from "@mui/material/Stack"; +import { + DataGrid, + GridColDef, + GridActionsCellItem, + GridToolbar, +} from "@mui/x-data-grid"; +import SearchIcon from "@mui/icons-material/Search"; +import AddIcon from "@mui/icons-material/Add"; +import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import { useCustomers } from "../hooks/useCustomers"; +import CustomerDetailsDialog from "../components/CustomerDetailsDialog"; +import CreateCustomerDialog from "../components/CreateCustomerDialog"; +import type { Customer } from "../types/customer"; export default function Customers() { + const [searchTerm, setSearchTerm] = React.useState(""); + const [sortBy, setSortBy] = React.useState("name.first"); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(20); + const [selectedCustomer, setSelectedCustomer] = + React.useState(null); + const [detailsDialogOpen, setDetailsDialogOpen] = React.useState(false); + const [createDialogOpen, setCreateDialogOpen] = React.useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); + const [customerToDelete, setCustomerToDelete] = + React.useState(null); + const [snackbar, setSnackbar] = React.useState<{ + open: boolean; + message: string; + severity: "success" | "error"; + }>({ open: false, message: "", severity: "success" }); + + const { + customers, + loading, + error, + total, + refetch, + createCustomer, + updateCustomer, + deleteCustomer, + } = useCustomers({ + page, + perPage: pageSize, + search: searchTerm, + sortBy, + }); + + const handleSearch = React.useCallback( + (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + setPage(1); // Reset to first page when searching + }, + [], + ); + + const handleSortChange = (event: any) => { + setSortBy(event.target.value); + setPage(1); + }; + + const handleViewCustomer = (customer: Customer) => { + setSelectedCustomer(customer); + setDetailsDialogOpen(true); + }; + + const handleEditCustomer = (customer: Customer) => { + setSelectedCustomer(customer); + setDetailsDialogOpen(true); + }; + + const handleDeleteClick = (customer: Customer) => { + setCustomerToDelete(customer); + setDeleteDialogOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!customerToDelete) return; + + try { + await deleteCustomer(customerToDelete.login.uuid); + setSnackbar({ + open: true, + message: "Customer deleted successfully", + severity: "success", + }); + setDeleteDialogOpen(false); + setCustomerToDelete(null); + } catch (err) { + setSnackbar({ + open: true, + message: + err instanceof Error ? err.message : "Failed to delete customer", + severity: "error", + }); + } + }; + + const handleCreateCustomer = async (data: any) => { + try { + await createCustomer(data); + setSnackbar({ + open: true, + message: "Customer created successfully", + severity: "success", + }); + } catch (err) { + throw err; // Let the dialog handle the error + } + }; + + const handleUpdateCustomer = async (id: string, data: any) => { + try { + await updateCustomer(id, data); + setSnackbar({ + open: true, + message: "Customer updated successfully", + severity: "success", + }); + } catch (err) { + throw err; // Let the dialog handle the error + } + }; + + const columns: GridColDef[] = [ + { + field: "avatar", + headerName: "", + width: 60, + sortable: false, + align: "center", + headerAlign: "center", + cellClassName: "avatar-cell", + renderCell: (params) => ( + + {params.row.name.first[0]} + {params.row.name.last[0]} + + ), + }, + { + field: "fullName", + headerName: "Name", + width: 200, + valueGetter: (value, row) => + `${row.name.title} ${row.name.first} ${row.name.last}`, + }, + { + field: "email", + headerName: "Email", + width: 250, + }, + { + field: "phone", + headerName: "Phone", + width: 150, + }, + { + field: "location", + headerName: "Location", + width: 200, + valueGetter: (value, row) => + `${row.location.city}, ${row.location.country}`, + }, + { + field: "gender", + headerName: "Gender", + width: 100, + renderCell: (params) => ( + + ), + }, + { + field: "age", + headerName: "Age", + width: 80, + valueGetter: (value, row) => row.dob.age, + }, + { + field: "registered", + headerName: "Registered", + width: 120, + valueGetter: (value, row) => + new Date(row.registered.date).toLocaleDateString(), + }, + { + field: "actions", + type: "actions", + headerName: "Actions", + width: 120, + getActions: (params) => [ + } + label="View" + onClick={() => handleViewCustomer(params.row)} + />, + } + label="Edit" + onClick={() => handleEditCustomer(params.row)} + />, + } + label="Delete" + onClick={() => handleDeleteClick(params.row)} + />, + ], + }, + ]; + + const rows = customers.map((customer) => ({ + id: customer.login.uuid, + ...customer, + })); + return ( - - Customers Page - - - This is the customers management page where you can view and manage your - customer data. - + + + Customers + + + + + {error && ( + + {error} + + )} + + + + + + ), + }} + /> + + + Sort by + + + + + + setPage(newPage + 1)} + onPageSizeChange={(newPageSize) => { + setPageSize(newPageSize); + setPage(1); + }} + pageSizeOptions={[10, 20, 50, 100]} + disableRowSelectionOnClick + rowHeight={64} + slots={{ + toolbar: GridToolbar, + }} + slotProps={{ + toolbar: { + showQuickFilter: false, + }, + }} + sx={{ + "& .MuiDataGrid-cell": { + display: "flex", + alignItems: "center", + }, + "& .avatar-cell": { + display: "flex !important", + alignItems: "center !important", + justifyContent: "center !important", + "& .MuiDataGrid-cellContent": { + display: "flex !important", + alignItems: "center !important", + justifyContent: "center !important", + width: "100%", + height: "100%", + }, + }, + "& .MuiDataGrid-row": { + "&:hover": { + backgroundColor: "action.hover", + }, + }, + }} + /> + + + {/* Customer Details Dialog */} + { + setDetailsDialogOpen(false); + setSelectedCustomer(null); + }} + onSave={handleUpdateCustomer} + /> + + {/* Create Customer Dialog */} + setCreateDialogOpen(false)} + onCreate={handleCreateCustomer} + /> + + {/* Delete Confirmation Dialog */} + setDeleteDialogOpen(false)} + > + Delete Customer + + + Are you sure you want to delete {customerToDelete?.name.first}{" "} + {customerToDelete?.name.last}? This action cannot be undone. + + + + + + + + + {/* Success/Error Snackbar */} + setSnackbar({ ...snackbar, open: false })} + > + setSnackbar({ ...snackbar, open: false })} + severity={snackbar.severity} + sx={{ width: "100%" }} + > + {snackbar.message} + + ); } diff --git a/src/crm/types/customer.ts b/src/crm/types/customer.ts new file mode 100644 index 0000000..fe8ef63 --- /dev/null +++ b/src/crm/types/customer.ts @@ -0,0 +1,105 @@ +export interface CustomerLogin { + uuid: string; + username: string; + password?: string; +} + +export interface CustomerName { + title: string; + first: string; + last: string; +} + +export interface CustomerStreet { + number: number; + name: string; +} + +export interface CustomerCoordinates { + latitude: number; + longitude: number; +} + +export interface CustomerTimezone { + offset: string; + description: string; +} + +export interface CustomerLocation { + street: CustomerStreet; + city: string; + state: string; + country: string; + postcode: string; + coordinates?: CustomerCoordinates; + timezone?: CustomerTimezone; +} + +export interface CustomerDateInfo { + date: string; + age: number; +} + +export interface CustomerPicture { + large: string; + medium: string; + thumbnail: string; +} + +export interface Customer { + login: CustomerLogin; + name: CustomerName; + gender: string; + location: CustomerLocation; + email: string; + dob: CustomerDateInfo; + registered: CustomerDateInfo; + phone: string; + cell: string; + picture?: CustomerPicture; + nat: string; +} + +export interface CustomersApiResponse { + page: number; + perPage: number; + total: number; + span: string; + effectivePage: number; + data: Customer[]; +} + +export interface CreateCustomerRequest { + 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; + }; + phone?: string; + cell?: string; +} + +export interface UpdateCustomerRequest { + name?: Partial; + location?: Partial; + email?: string; + phone?: string; + cell?: string; + gender?: string; +}