4369 lines
170 KiB
JavaScript
4369 lines
170 KiB
JavaScript
import React, { useState, useEffect, useRef } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import axios from "axios";
|
|
import {
|
|
LayoutDashboard,
|
|
Package,
|
|
Wrench,
|
|
ShoppingCart,
|
|
Users,
|
|
BarChart3,
|
|
Settings,
|
|
AlertTriangle,
|
|
TrendingUp,
|
|
DollarSign,
|
|
Calendar,
|
|
Download,
|
|
Plus,
|
|
Edit,
|
|
Trash2,
|
|
Eye,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Search,
|
|
Filter,
|
|
RefreshCw,
|
|
FileText,
|
|
FileSpreadsheet,
|
|
Info,
|
|
Image,
|
|
Printer,
|
|
CheckCircle,
|
|
Clock,
|
|
} from "lucide-react";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Badge } from "../components/ui/badge";
|
|
import { Separator } from "../components/ui/separator";
|
|
import {
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
} from "../components/ui/tabs";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "../components/ui/select";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
DialogFooter,
|
|
} from "../components/ui/dialog";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "../components/ui/alert-dialog";
|
|
import { Label } from "../components/ui/label";
|
|
import { Textarea } from "../components/ui/textarea";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import { toast } from "sonner";
|
|
import RichTextEditor from "../components/RichTextEditor";
|
|
import ImageUploadManager from "../components/ImageUploadManager";
|
|
import MediaManager from "../components/MediaManager";
|
|
import { clearCache } from "../utils/apiCache";
|
|
|
|
const API = `${process.env.REACT_APP_BACKEND_URL}/api`;
|
|
|
|
const statusColors = {
|
|
pending: "bg-yellow-500",
|
|
processing: "bg-blue-500",
|
|
layaway: "bg-purple-500",
|
|
shipped: "bg-cyan-500",
|
|
delivered: "bg-green-500",
|
|
cancelled: "bg-red-500",
|
|
refunded: "bg-orange-500",
|
|
on_hold: "bg-gray-500",
|
|
};
|
|
|
|
const AdminDashboard = () => {
|
|
const { user, token, isAuthenticated } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [activeTab, setActiveTab] = useState("dashboard");
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Dashboard data
|
|
const [dashboardData, setDashboardData] = useState(null);
|
|
|
|
// Products state
|
|
const [products, setProducts] = useState([]);
|
|
const [productDialog, setProductDialog] = useState(false);
|
|
const [editingProduct, setEditingProduct] = useState(null);
|
|
const [productForm, setProductForm] = useState({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
category: "",
|
|
image_url: "",
|
|
stock: "",
|
|
brand: "",
|
|
images: [],
|
|
});
|
|
|
|
// Services state
|
|
const [services, setServices] = useState([]);
|
|
const [serviceDialog, setServiceDialog] = useState(false);
|
|
const [editingService, setEditingService] = useState(null);
|
|
const [serviceForm, setServiceForm] = useState({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
duration: "",
|
|
image_url: "",
|
|
category: "",
|
|
images: [],
|
|
});
|
|
|
|
// Orders state
|
|
const [orders, setOrders] = useState([]);
|
|
const [orderStatusFilter, setOrderStatusFilter] = useState("all");
|
|
const [selectedOrder, setSelectedOrder] = useState(null);
|
|
const [newStatus, setNewStatus] = useState("");
|
|
const [statusNotes, setStatusNotes] = useState("");
|
|
const [trackingNumber, setTrackingNumber] = useState("");
|
|
|
|
// Inventory state
|
|
const [inventory, setInventory] = useState([]);
|
|
const [adjustDialog, setAdjustDialog] = useState(false);
|
|
const [adjustProduct, setAdjustProduct] = useState(null);
|
|
const [adjustQty, setAdjustQty] = useState(0);
|
|
const [adjustNotes, setAdjustNotes] = useState("");
|
|
const [inventorySearch, setInventorySearch] = useState("");
|
|
const [inventoryCategory, setInventoryCategory] = useState("all");
|
|
const [inventoryItemsPerPage, setInventoryItemsPerPage] = useState(20);
|
|
const [inventoryCurrentPage, setInventoryCurrentPage] = useState(1);
|
|
|
|
// Reports state
|
|
const [reportPeriod, setReportPeriod] = useState("monthly");
|
|
const [salesReport, setSalesReport] = useState(null);
|
|
|
|
// Bookings state
|
|
const [bookings, setBookings] = useState([]);
|
|
const [bookingCompleteDialog, setBookingCompleteDialog] = useState(false);
|
|
const [selectedBooking, setSelectedBooking] = useState(null);
|
|
const [bookingCompleteForm, setBookingCompleteForm] = useState({
|
|
diagnosis: "",
|
|
work_performed: "",
|
|
technician_notes: "",
|
|
service_cost: "",
|
|
paid: true,
|
|
device_model: "",
|
|
serial_number: "",
|
|
product_number: "",
|
|
screen_size: "",
|
|
});
|
|
const [receiptData, setReceiptData] = useState(null);
|
|
const [showReceiptDialog, setShowReceiptDialog] = useState(false);
|
|
const [isEditMode, setIsEditMode] = useState(false);
|
|
const receiptRef = useRef(null);
|
|
|
|
// Categories state
|
|
const [categories, setCategories] = useState([]);
|
|
const [categoryDialog, setCategoryDialog] = useState(false);
|
|
const [editingCategory, setEditingCategory] = useState(null);
|
|
const [categoryForm, setCategoryForm] = useState({
|
|
name: "",
|
|
description: "",
|
|
});
|
|
|
|
// Users state
|
|
const [users, setUsers] = useState([]);
|
|
const [usersTotal, setUsersTotal] = useState(0);
|
|
const [userDialog, setUserDialog] = useState(false);
|
|
const [editingUser, setEditingUser] = useState(null);
|
|
const [userForm, setUserForm] = useState({
|
|
name: "",
|
|
email: "",
|
|
password: "",
|
|
role: "user",
|
|
is_active: true,
|
|
});
|
|
const [userSearch, setUserSearch] = useState("");
|
|
const [userRoleFilter, setUserRoleFilter] = useState("");
|
|
const [userStatusFilter, setUserStatusFilter] = useState("");
|
|
const [usersPerPage, setUsersPerPage] = useState(20);
|
|
const [currentUsersPage, setCurrentUsersPage] = useState(1);
|
|
|
|
// Delete confirmation state (generic for all delete operations)
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
const [productToDelete, setProductToDelete] = useState(null);
|
|
const [confirmDialog, setConfirmDialog] = useState({
|
|
open: false,
|
|
title: "Confirm Action",
|
|
message: "Are you sure you want to proceed?",
|
|
onConfirm: null,
|
|
});
|
|
|
|
// About Page state
|
|
const [aboutContent, setAboutContent] = useState([]);
|
|
const [teamMembers, setTeamMembers] = useState([]);
|
|
const [companyValues, setCompanyValues] = useState([]);
|
|
const [aboutDialog, setAboutDialog] = useState(false);
|
|
const [aboutEditType, setAboutEditType] = useState(""); // "content", "team", "value"
|
|
const [editingAboutItem, setEditingAboutItem] = useState(null);
|
|
const [aboutForm, setAboutForm] = useState({
|
|
section: "",
|
|
title: "",
|
|
subtitle: "",
|
|
content: "",
|
|
image_url: "",
|
|
data: null,
|
|
display_order: 0,
|
|
is_active: true,
|
|
name: "",
|
|
role: "",
|
|
bio: "",
|
|
email: "",
|
|
linkedin: "",
|
|
description: "",
|
|
icon: "",
|
|
});
|
|
|
|
// Helper function to show confirmation dialog
|
|
const showConfirmDialog = (title, message, onConfirm) => {
|
|
setConfirmDialog({
|
|
open: true,
|
|
title,
|
|
message,
|
|
onConfirm,
|
|
});
|
|
};
|
|
|
|
const handleConfirmAction = () => {
|
|
if (confirmDialog.onConfirm) {
|
|
confirmDialog.onConfirm();
|
|
}
|
|
setConfirmDialog({ ...confirmDialog, open: false, onConfirm: null });
|
|
};
|
|
|
|
const handleCancelConfirm = () => {
|
|
setConfirmDialog({ ...confirmDialog, open: false, onConfirm: null });
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Only fetch if properly authenticated as admin
|
|
if (isAuthenticated && user?.role === "admin" && token) {
|
|
fetchDashboardData();
|
|
fetchCategories(); // Fetch categories on mount for product form dropdown
|
|
} else if (isAuthenticated && user && user.role !== "admin") {
|
|
// User is authenticated but not admin
|
|
toast.error("Admin access required");
|
|
navigate("/");
|
|
}
|
|
}, [isAuthenticated, user, token, navigate]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === "products") fetchProducts();
|
|
if (activeTab === "services") fetchServices();
|
|
if (activeTab === "orders") fetchOrders();
|
|
if (activeTab === "inventory") fetchInventory();
|
|
if (activeTab === "reports") fetchReports();
|
|
if (activeTab === "bookings") fetchBookings();
|
|
if (activeTab === "categories") fetchCategories();
|
|
if (activeTab === "users") fetchUsers();
|
|
if (activeTab === "about") fetchAboutData();
|
|
}, [activeTab]);
|
|
|
|
// Fetch users when filters or pagination change
|
|
useEffect(() => {
|
|
if (activeTab === "users") {
|
|
fetchUsers();
|
|
}
|
|
}, [
|
|
currentUsersPage,
|
|
usersPerPage,
|
|
userSearch,
|
|
userRoleFilter,
|
|
userStatusFilter,
|
|
]);
|
|
|
|
const fetchDashboardData = async () => {
|
|
try {
|
|
// Validate token exists
|
|
if (!token) {
|
|
console.warn("No authentication token available");
|
|
toast.error("Authentication required");
|
|
navigate("/login");
|
|
return;
|
|
}
|
|
|
|
const response = await axios.get(`${API}/admin/dashboard`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
timeout: 10000, // 10 second timeout
|
|
});
|
|
|
|
// Validate response structure
|
|
if (response.data && response.data.stats) {
|
|
setDashboardData(response.data);
|
|
|
|
// Show warning if partial data
|
|
if (response.data.error) {
|
|
toast.warning(response.data.error);
|
|
}
|
|
} else {
|
|
console.error("Invalid dashboard response structure:", response.data);
|
|
toast.error("Received invalid dashboard data");
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to fetch dashboard:", error);
|
|
|
|
// Detailed error handling
|
|
if (error.response) {
|
|
// Server responded with error
|
|
if (error.response.status === 401) {
|
|
toast.error("Session expired. Please login again.");
|
|
navigate("/login");
|
|
} else if (error.response.status === 403) {
|
|
toast.error("Admin access required");
|
|
navigate("/");
|
|
} else if (error.response.status === 500) {
|
|
toast.error("Server error. Please try again later.");
|
|
} else {
|
|
toast.error(
|
|
`Failed to load dashboard data (Error ${error.response.status})`,
|
|
);
|
|
}
|
|
} else if (error.request) {
|
|
// Request made but no response
|
|
toast.error("Cannot connect to server. Please check your connection.");
|
|
} else {
|
|
// Other errors
|
|
toast.error("Failed to load dashboard data");
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchProducts = async () => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/products?include_inactive=true`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
setProducts(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load products");
|
|
}
|
|
};
|
|
|
|
const fetchServices = async () => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/services?include_inactive=true`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
setServices(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load services");
|
|
}
|
|
};
|
|
|
|
const fetchOrders = async () => {
|
|
try {
|
|
const params =
|
|
orderStatusFilter !== "all" ? `?status=${orderStatusFilter}` : "";
|
|
const response = await axios.get(`${API}/admin/orders${params}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
setOrders(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load orders");
|
|
}
|
|
};
|
|
|
|
const fetchInventory = async () => {
|
|
try {
|
|
const response = await axios.get(`${API}/admin/inventory`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
setInventory(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load inventory");
|
|
}
|
|
};
|
|
|
|
const fetchReports = async () => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/reports/sales?period=${reportPeriod}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
setSalesReport(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load reports");
|
|
}
|
|
};
|
|
|
|
const fetchBookings = async () => {
|
|
try {
|
|
const response = await axios.get(`${API}/admin/bookings`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
setBookings(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load bookings");
|
|
}
|
|
};
|
|
|
|
const fetchCategories = async () => {
|
|
try {
|
|
const response = await axios.get(`${API}/admin/categories`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
setCategories(response.data);
|
|
} catch (error) {
|
|
toast.error("Failed to load categories");
|
|
}
|
|
};
|
|
|
|
const fetchUsers = async () => {
|
|
try {
|
|
const skip = (currentUsersPage - 1) * usersPerPage;
|
|
const response = await axios.get(`${API}/admin/users`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
params: {
|
|
skip,
|
|
limit: usersPerPage,
|
|
search: userSearch,
|
|
role: userRoleFilter,
|
|
status: userStatusFilter,
|
|
},
|
|
});
|
|
setUsers(response.data.users);
|
|
setUsersTotal(response.data.total);
|
|
} catch (error) {
|
|
toast.error("Failed to load users");
|
|
}
|
|
};
|
|
|
|
const handleUserSubmit = async () => {
|
|
try {
|
|
const data = {
|
|
...userForm,
|
|
role: userForm.role.toLowerCase(),
|
|
};
|
|
|
|
if (editingUser) {
|
|
// Update user - only include password if it's not empty
|
|
if (!data.password || data.password.trim() === "") {
|
|
delete data.password; // Don't send empty password
|
|
}
|
|
await axios.put(`${API}/admin/users/${editingUser.id}`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("User updated successfully");
|
|
} else {
|
|
// Create new user
|
|
if (!data.password) {
|
|
toast.error("Password is required for new users");
|
|
return;
|
|
}
|
|
await axios.post(`${API}/admin/users`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("User created");
|
|
}
|
|
|
|
setUserDialog(false);
|
|
setEditingUser(null);
|
|
setUserForm({
|
|
name: "",
|
|
email: "",
|
|
password: "",
|
|
role: "user",
|
|
is_active: true,
|
|
});
|
|
fetchUsers();
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || "Failed to save user");
|
|
}
|
|
};
|
|
|
|
const handleToggleUserActive = async (userId) => {
|
|
try {
|
|
await axios.put(
|
|
`${API}/admin/users/${userId}/toggle-active`,
|
|
{},
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
toast.success("User status updated");
|
|
fetchUsers();
|
|
} catch (error) {
|
|
toast.error(
|
|
error.response?.data?.detail || "Failed to update user status",
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDeleteUser = async (userId) => {
|
|
try {
|
|
await axios.delete(`${API}/admin/users/${userId}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("User deleted");
|
|
fetchUsers();
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || "Failed to delete user");
|
|
}
|
|
};
|
|
|
|
// About Page Functions
|
|
const fetchAboutData = async () => {
|
|
try {
|
|
const [contentRes, teamRes, valuesRes] = await Promise.all([
|
|
axios.get(`${API}/admin/about/content`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
axios.get(`${API}/admin/about/team`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
axios.get(`${API}/admin/about/values`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
}),
|
|
]);
|
|
setAboutContent(contentRes.data);
|
|
setTeamMembers(teamRes.data);
|
|
setCompanyValues(valuesRes.data);
|
|
} catch (error) {
|
|
toast.error("Failed to fetch about page data");
|
|
}
|
|
};
|
|
|
|
const handleSaveAboutItem = async () => {
|
|
try {
|
|
if (aboutEditType === "content") {
|
|
const data = {
|
|
section: aboutForm.section,
|
|
title: aboutForm.title || null,
|
|
subtitle: aboutForm.subtitle || null,
|
|
content: aboutForm.content || null,
|
|
image_url: aboutForm.image_url || null,
|
|
data: aboutForm.data,
|
|
display_order: aboutForm.display_order,
|
|
is_active: aboutForm.is_active,
|
|
};
|
|
|
|
if (editingAboutItem) {
|
|
await axios.put(
|
|
`${API}/admin/about/content/${editingAboutItem.id}`,
|
|
data,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success("Content updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/about/content`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Content created");
|
|
}
|
|
} else if (aboutEditType === "team") {
|
|
const data = {
|
|
name: aboutForm.name,
|
|
role: aboutForm.role,
|
|
bio: aboutForm.bio || null,
|
|
image_url: aboutForm.image_url || null,
|
|
email: aboutForm.email || null,
|
|
linkedin: aboutForm.linkedin || null,
|
|
display_order: aboutForm.display_order,
|
|
is_active: aboutForm.is_active,
|
|
};
|
|
|
|
if (editingAboutItem) {
|
|
await axios.put(
|
|
`${API}/admin/about/team/${editingAboutItem.id}`,
|
|
data,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success("Team member updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/about/team`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Team member created");
|
|
}
|
|
} else if (aboutEditType === "value") {
|
|
const data = {
|
|
title: aboutForm.title,
|
|
description: aboutForm.description,
|
|
icon: aboutForm.icon,
|
|
display_order: aboutForm.display_order,
|
|
is_active: aboutForm.is_active,
|
|
};
|
|
|
|
if (editingAboutItem) {
|
|
await axios.put(
|
|
`${API}/admin/about/values/${editingAboutItem.id}`,
|
|
data,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success("Company value updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/about/values`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Company value created");
|
|
}
|
|
}
|
|
|
|
setAboutDialog(false);
|
|
setEditingAboutItem(null);
|
|
fetchAboutData();
|
|
} catch (error) {
|
|
toast.error(
|
|
error.response?.data?.detail || "Failed to save about page item",
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDeleteAboutItem = (id, type) => {
|
|
const itemName =
|
|
type === "content"
|
|
? "content section"
|
|
: type === "team"
|
|
? "team member"
|
|
: "company value";
|
|
showConfirmDialog(
|
|
"Delete Item",
|
|
`Are you sure you want to delete this ${itemName}? This action cannot be undone.`,
|
|
async () => {
|
|
try {
|
|
if (type === "content") {
|
|
await axios.delete(`${API}/admin/about/content/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Content deleted");
|
|
} else if (type === "team") {
|
|
await axios.delete(`${API}/admin/about/team/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Team member deleted");
|
|
} else if (type === "value") {
|
|
await axios.delete(`${API}/admin/about/values/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Company value deleted");
|
|
}
|
|
fetchAboutData();
|
|
} catch (error) {
|
|
toast.error(
|
|
error.response?.data?.detail || "Failed to delete about page item",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
// Product CRUD
|
|
const handleProductSubmit = async () => {
|
|
try {
|
|
const price = parseFloat(productForm.price);
|
|
if (isNaN(price) || price <= 0) {
|
|
toast.error("Price must be greater than 0");
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
...productForm,
|
|
price: price,
|
|
stock: parseInt(productForm.stock) || 10,
|
|
};
|
|
|
|
if (editingProduct) {
|
|
await axios.put(`${API}/admin/products/${editingProduct.id}`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Product updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/products`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Product created");
|
|
}
|
|
|
|
clearCache("products-");
|
|
setProductDialog(false);
|
|
setEditingProduct(null);
|
|
setProductForm({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
category: "",
|
|
image_url: "",
|
|
stock: "",
|
|
brand: "",
|
|
images: [],
|
|
});
|
|
fetchProducts();
|
|
} catch (error) {
|
|
toast.error("Failed to save product");
|
|
}
|
|
};
|
|
|
|
const handleDeleteProduct = async () => {
|
|
if (!productToDelete) return;
|
|
try {
|
|
await axios.delete(`${API}/admin/products/${productToDelete.id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success(`"${productToDelete.name}" has been deleted`);
|
|
clearCache("products-");
|
|
setDeleteConfirmOpen(false);
|
|
setProductToDelete(null);
|
|
fetchProducts();
|
|
} catch (error) {
|
|
toast.error("Failed to delete product");
|
|
setDeleteConfirmOpen(false);
|
|
setProductToDelete(null);
|
|
}
|
|
};
|
|
|
|
// Service CRUD
|
|
const handleServiceSubmit = async () => {
|
|
try {
|
|
const price = parseFloat(serviceForm.price) || 0;
|
|
if (isNaN(price) || price < 0) {
|
|
toast.error("Price cannot be negative");
|
|
return;
|
|
}
|
|
|
|
const data = {
|
|
...serviceForm,
|
|
price: price,
|
|
};
|
|
|
|
if (editingService) {
|
|
await axios.put(`${API}/admin/services/${editingService.id}`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Service updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/services`, data, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Service created");
|
|
}
|
|
|
|
clearCache("services-");
|
|
setServiceDialog(false);
|
|
setEditingService(null);
|
|
setServiceForm({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
duration: "",
|
|
image_url: "",
|
|
category: "",
|
|
images: [],
|
|
});
|
|
fetchServices();
|
|
} catch (error) {
|
|
toast.error("Failed to save service");
|
|
}
|
|
};
|
|
|
|
const handleDeleteService = (id) => {
|
|
showConfirmDialog(
|
|
"Delete Service",
|
|
"Are you sure you want to delete this service? This action cannot be undone.",
|
|
async () => {
|
|
try {
|
|
await axios.delete(`${API}/admin/services/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Service deleted");
|
|
clearCache("services-");
|
|
fetchServices();
|
|
} catch (error) {
|
|
toast.error("Failed to delete service");
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
// Booking completion
|
|
const handleOpenCompleteDialog = (booking) => {
|
|
setSelectedBooking(booking);
|
|
setBookingCompleteForm({
|
|
diagnosis: booking.diagnosis || "",
|
|
work_performed: booking.work_performed || "",
|
|
technician_notes: booking.technician_notes || "",
|
|
service_cost: booking.service_cost || "",
|
|
paid: booking.paid !== undefined ? booking.paid : true,
|
|
device_model: booking.device_model || "",
|
|
serial_number: booking.serial_number || "",
|
|
product_number: booking.product_number || "",
|
|
screen_size: booking.screen_size || "",
|
|
});
|
|
setIsEditMode(false);
|
|
setBookingCompleteDialog(true);
|
|
};
|
|
|
|
const handleDeleteBooking = (booking) => {
|
|
showConfirmDialog(
|
|
"Delete Booking",
|
|
`Are you sure you want to delete the booking for "${booking.name}"? This action cannot be undone.`,
|
|
async () => {
|
|
try {
|
|
await axios.delete(`${API}/admin/bookings/${booking.id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Booking deleted successfully");
|
|
fetchBookings();
|
|
} catch (error) {
|
|
toast.error("Failed to delete booking");
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleCompleteBooking = async () => {
|
|
if (!selectedBooking) return;
|
|
try {
|
|
const data = {
|
|
...bookingCompleteForm,
|
|
service_cost: bookingCompleteForm.service_cost
|
|
? parseFloat(bookingCompleteForm.service_cost)
|
|
: null,
|
|
};
|
|
await axios.put(
|
|
`${API}/admin/bookings/${selectedBooking.id}/complete`,
|
|
data,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success("Service marked as completed!");
|
|
setBookingCompleteDialog(false);
|
|
setSelectedBooking(null);
|
|
fetchBookings();
|
|
} catch (error) {
|
|
toast.error("Failed to complete booking");
|
|
}
|
|
};
|
|
|
|
const handleUpdateBookingStatus = async (bookingId, status) => {
|
|
try {
|
|
await axios.put(
|
|
`${API}/admin/bookings/${bookingId}/status?status=${status}`,
|
|
{},
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success(`Booking status updated to ${status}`);
|
|
fetchBookings();
|
|
} catch (error) {
|
|
toast.error("Failed to update status");
|
|
}
|
|
};
|
|
|
|
const handlePrintReceipt = async (booking) => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/bookings/${booking.id}/receipt`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
setReceiptData(response.data);
|
|
setShowReceiptDialog(true);
|
|
} catch (error) {
|
|
toast.error("Failed to load receipt data");
|
|
}
|
|
};
|
|
|
|
const printReceipt = () => {
|
|
if (!receiptData) return;
|
|
|
|
const printWindow = window.open("", "_blank");
|
|
printWindow.document.write(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Service Receipt - PromptTech Solutions</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
padding: 15px;
|
|
color: #333;
|
|
font-size: 13px;
|
|
line-height: 1.4;
|
|
}
|
|
.receipt {
|
|
max-width: 700px;
|
|
margin: 0 auto;
|
|
border: 1px solid #ddd;
|
|
padding: 15px 20px;
|
|
}
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 12px;
|
|
border-bottom: 2px solid #2563eb;
|
|
padding-bottom: 10px;
|
|
}
|
|
.logo {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
margin-bottom: 5px;
|
|
}
|
|
.logo img {
|
|
height: 40px;
|
|
width: auto;
|
|
max-width: 100px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.logo-text {
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
color: #2563eb;
|
|
}
|
|
.company-info { font-size: 12px; color: #666; }
|
|
.receipt-title {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
margin: 10px 0;
|
|
text-align: center;
|
|
}
|
|
.section { margin-bottom: 10px; }
|
|
.section-title {
|
|
font-weight: bold;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid #eee;
|
|
padding-bottom: 3px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 3px 15px;
|
|
}
|
|
.grid-item { font-size: 13px; }
|
|
.grid-item .label { font-weight: 500; color: #666; }
|
|
.notes-box {
|
|
background: #f5f5f5;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
margin-top: 5px;
|
|
font-size: 13px;
|
|
}
|
|
.notes-label { font-weight: 500; color: #666; margin-bottom: 3px; }
|
|
.total {
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
text-align: right;
|
|
padding-top: 10px;
|
|
margin-top: 10px;
|
|
border-top: 2px solid #333;
|
|
}
|
|
.footer {
|
|
text-align: center;
|
|
margin-top: 15px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid #eee;
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
@media print {
|
|
@page {
|
|
margin: 0.5in;
|
|
size: auto;
|
|
}
|
|
body {
|
|
-webkit-print-color-adjust: exact;
|
|
print-color-adjust: exact;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.receipt { border: none; padding: 10px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="receipt">
|
|
<div class="header">
|
|
<div class="logo">
|
|
<img src="/logo.png" alt="PromptTech Solutions" onerror="this.style.display='none'" />
|
|
<span class="logo-text">PromptTech Solutions</span>
|
|
</div>
|
|
<div class="company-info">
|
|
<p>${receiptData.company?.address || "Belmopan City, Cayo District, Belize"}</p>
|
|
<p>Phone: ${receiptData.company?.phone || "+501 638-6318"} | Email: ${receiptData.company?.email || "prompttechbz@gmail.com"}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="receipt-title">SERVICE RECEIPT</h2>
|
|
|
|
<div class="section">
|
|
<h3 class="section-title">Customer Information</h3>
|
|
<div class="grid">
|
|
<div class="grid-item"><span class="label">Name:</span> ${receiptData.name || ""}</div>
|
|
<div class="grid-item"><span class="label">Phone:</span> ${receiptData.phone || ""}</div>
|
|
<div class="grid-item"><span class="label">Email:</span> ${receiptData.email || ""}</div>
|
|
<div class="grid-item"><span class="label">Date Booked:</span> ${receiptData.created_at ? new Date(receiptData.created_at).toLocaleDateString() : ""}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3 class="section-title">Service Details</h3>
|
|
<div class="grid">
|
|
<div class="grid-item"><span class="label">Service:</span> ${receiptData.service_name || ""}</div>
|
|
<div class="grid-item"><span class="label">Preferred Date:</span> ${receiptData.preferred_date || ""}</div>
|
|
<div class="grid-item"><span class="label">Status:</span> ${receiptData.status || ""}</div>
|
|
${receiptData.completed_at ? `<div class="grid-item"><span class="label">Completed:</span> ${new Date(receiptData.completed_at).toLocaleDateString()}</div>` : ""}
|
|
</div>
|
|
</div>
|
|
|
|
${
|
|
receiptData.device_model ||
|
|
receiptData.serial_number ||
|
|
receiptData.product_number ||
|
|
receiptData.screen_size
|
|
? `
|
|
<div class="section">
|
|
<h3 class="section-title">Device Information</h3>
|
|
<div class="grid">
|
|
${receiptData.device_model ? `<div class="grid-item"><span class="label">Model:</span> ${receiptData.device_model}</div>` : ""}
|
|
${receiptData.screen_size ? `<div class="grid-item"><span class="label">Screen Size:</span> ${receiptData.screen_size}</div>` : ""}
|
|
${receiptData.serial_number ? `<div class="grid-item"><span class="label">Serial #:</span> ${receiptData.serial_number}</div>` : ""}
|
|
${receiptData.product_number ? `<div class="grid-item"><span class="label">Product #:</span> ${receiptData.product_number}</div>` : ""}
|
|
</div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
receiptData.diagnosis || receiptData.work_performed
|
|
? `
|
|
<div class="section">
|
|
<h3 class="section-title">Service Report</h3>
|
|
${
|
|
receiptData.diagnosis
|
|
? `
|
|
<div class="notes-box">
|
|
<p class="notes-label">Diagnosis:</p>
|
|
<p>${receiptData.diagnosis}</p>
|
|
</div>`
|
|
: ""
|
|
}
|
|
${
|
|
receiptData.work_performed
|
|
? `
|
|
<div class="notes-box" style="margin-top: 5px;">
|
|
<p class="notes-label">Work Performed:</p>
|
|
<p>${receiptData.work_performed}</p>
|
|
</div>`
|
|
: ""
|
|
}
|
|
</div>`
|
|
: ""
|
|
}
|
|
|
|
${
|
|
receiptData.notes
|
|
? `
|
|
<div class="section">
|
|
<h3 class="section-title">Customer Notes</h3>
|
|
<div class="notes-box">
|
|
<p>${receiptData.notes}</p>
|
|
</div>
|
|
</div>`
|
|
: ""
|
|
}
|
|
|
|
<div class="total">
|
|
Total: $${receiptData.final_cost?.toFixed(2) || "0.00"}
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Thank you for choosing PromptTech Solutions!</p>
|
|
<p>Questions? Contact us at ${receiptData.company?.phone || "+501 638-6318"}</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`);
|
|
printWindow.document.close();
|
|
// Small delay to ensure styles are loaded before printing
|
|
setTimeout(() => {
|
|
printWindow.print();
|
|
}, 250);
|
|
};
|
|
|
|
// Order status update
|
|
const handleUpdateOrderStatus = async () => {
|
|
if (!selectedOrder || !newStatus) return;
|
|
try {
|
|
await axios.put(
|
|
`${API}/admin/orders/${selectedOrder}/status`,
|
|
{
|
|
status: newStatus,
|
|
notes: statusNotes,
|
|
tracking_number: trackingNumber || null,
|
|
},
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
toast.success("Order status updated");
|
|
setSelectedOrder(null);
|
|
setNewStatus("");
|
|
setStatusNotes("");
|
|
setTrackingNumber("");
|
|
fetchOrders();
|
|
} catch (error) {
|
|
toast.error("Failed to update order");
|
|
}
|
|
};
|
|
|
|
// Inventory adjustment
|
|
const handleAdjustInventory = async () => {
|
|
if (!adjustProduct) return;
|
|
try {
|
|
await axios.post(
|
|
`${API}/admin/inventory/${adjustProduct.id}/adjust`,
|
|
{
|
|
quantity_change: adjustQty,
|
|
notes: adjustNotes,
|
|
},
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
toast.success("Inventory adjusted");
|
|
setAdjustDialog(false);
|
|
setAdjustProduct(null);
|
|
setAdjustQty(0);
|
|
setAdjustNotes("");
|
|
fetchInventory();
|
|
} catch (error) {
|
|
toast.error("Failed to adjust inventory");
|
|
}
|
|
};
|
|
|
|
// Toggle product active status
|
|
const handleToggleActive = async (product) => {
|
|
try {
|
|
await axios.put(
|
|
`${API}/admin/products/${product.id}`,
|
|
{ is_active: !product.is_active },
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
);
|
|
toast.success(
|
|
`Product ${!product.is_active ? "activated" : "deactivated"}`,
|
|
);
|
|
fetchInventory();
|
|
} catch (error) {
|
|
toast.error("Failed to update product status");
|
|
}
|
|
};
|
|
|
|
// Export reports
|
|
const handleExportCSV = async (type) => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/reports/export/csv?report_type=${type}&period=${reportPeriod}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
responseType: "blob",
|
|
},
|
|
);
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.setAttribute("download", `${type}_report.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
toast.success("CSV exported");
|
|
} catch (error) {
|
|
toast.error("Failed to export CSV");
|
|
}
|
|
};
|
|
|
|
const handleExportPDF = async (type) => {
|
|
try {
|
|
const response = await axios.get(
|
|
`${API}/admin/reports/export/pdf?report_type=${type}&period=${reportPeriod}`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
responseType: "blob",
|
|
},
|
|
);
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.setAttribute("download", `${type}_report.pdf`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
toast.success("PDF exported");
|
|
} catch (error) {
|
|
toast.error("Failed to export PDF");
|
|
}
|
|
};
|
|
|
|
if (!isAuthenticated || user?.role !== "admin") {
|
|
return (
|
|
<div className="min-h-screen py-12 md:py-16">
|
|
<div className="max-w-4xl mx-auto px-4 md:px-8 text-center py-16">
|
|
<h2 className="text-2xl font-bold mb-4 font-['Outfit']">
|
|
Access Denied
|
|
</h2>
|
|
<p className="text-muted-foreground mb-8">
|
|
You need admin privileges to access this page.
|
|
</p>
|
|
<Button onClick={() => navigate("/login")} className="rounded-full">
|
|
Sign In as Admin
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Category CRUD
|
|
const handleCategorySubmit = async () => {
|
|
try {
|
|
if (editingCategory) {
|
|
await axios.put(
|
|
`${API}/admin/categories/${editingCategory.id}`,
|
|
categoryForm,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
toast.success("Category updated");
|
|
} else {
|
|
await axios.post(`${API}/admin/categories`, categoryForm, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Category created");
|
|
}
|
|
|
|
setCategoryDialog(false);
|
|
setEditingCategory(null);
|
|
setCategoryForm({ name: "", description: "" });
|
|
fetchCategories();
|
|
} catch (error) {
|
|
toast.error("Failed to save category");
|
|
}
|
|
};
|
|
|
|
const handleDeleteCategory = (id) => {
|
|
showConfirmDialog(
|
|
"Delete Category",
|
|
"Are you sure you want to delete this category? This action cannot be undone.",
|
|
async () => {
|
|
try {
|
|
await axios.delete(`${API}/admin/categories/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
toast.success("Category deleted");
|
|
fetchCategories();
|
|
} catch (error) {
|
|
toast.error("Failed to delete category");
|
|
}
|
|
},
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen py-12 flex items-center justify-center">
|
|
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-muted/30">
|
|
<div className="max-w-7xl mx-auto px-4 md:px-8 py-8">
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className="text-3xl font-bold font-['Outfit']">
|
|
Admin Dashboard
|
|
</h1>
|
|
<p className="text-muted-foreground">Manage your store</p>
|
|
</div>
|
|
<Button
|
|
onClick={fetchDashboardData}
|
|
variant="outline"
|
|
className="gap-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="grid grid-cols-4 md:grid-cols-8 gap-2 mb-8 h-auto p-1">
|
|
<TabsTrigger value="dashboard" className="gap-2">
|
|
<LayoutDashboard className="h-4 w-4" />
|
|
<span className="hidden md:inline">Dashboard</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="products" className="gap-2">
|
|
<Package className="h-4 w-4" />
|
|
<span className="hidden md:inline">Products</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="services" className="gap-2">
|
|
<Wrench className="h-4 w-4" />
|
|
<span className="hidden md:inline">Services</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="orders" className="gap-2">
|
|
<ShoppingCart className="h-4 w-4" />
|
|
<span className="hidden md:inline">Orders</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="inventory" className="gap-2">
|
|
<Package className="h-4 w-4" />
|
|
<span className="hidden md:inline">Inventory</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="bookings" className="gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
<span className="hidden md:inline">Bookings</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="categories" className="gap-2">
|
|
<Settings className="h-4 w-4" />
|
|
<span className="hidden md:inline">Categories</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="users" className="gap-2">
|
|
<Users className="h-4 w-4" />
|
|
<span className="hidden md:inline">Users</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="reports" className="gap-2">
|
|
<BarChart3 className="h-4 w-4" />
|
|
<span className="hidden md:inline">Reports</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="media" className="gap-2">
|
|
<Image className="h-4 w-4" />
|
|
<span className="hidden md:inline">Media</span>
|
|
</TabsTrigger>
|
|
<TabsTrigger value="about" className="gap-2">
|
|
<Info className="h-4 w-4" />
|
|
<span className="hidden md:inline">About Page</span>
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Dashboard Tab */}
|
|
<TabsContent value="dashboard">
|
|
{dashboardData && (
|
|
<div className="space-y-6">
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-full bg-blue-500/10 flex items-center justify-center">
|
|
<DollarSign className="h-5 w-5 text-blue-500" />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
Total Revenue
|
|
</span>
|
|
</div>
|
|
<p className="text-2xl font-bold font-['Outfit']">
|
|
${dashboardData.stats.total_revenue.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-full bg-green-500/10 flex items-center justify-center">
|
|
<ShoppingCart className="h-5 w-5 text-green-500" />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
Total Orders
|
|
</span>
|
|
</div>
|
|
<p className="text-2xl font-bold font-['Outfit']">
|
|
{dashboardData.stats.total_orders}
|
|
</p>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-full bg-purple-500/10 flex items-center justify-center">
|
|
<Package className="h-5 w-5 text-purple-500" />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
Products
|
|
</span>
|
|
</div>
|
|
<p className="text-2xl font-bold font-['Outfit']">
|
|
{dashboardData.stats.total_products}
|
|
</p>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<div className="w-10 h-10 rounded-full bg-orange-500/10 flex items-center justify-center">
|
|
<Users className="h-5 w-5 text-orange-500" />
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">
|
|
Users
|
|
</span>
|
|
</div>
|
|
<p className="text-2xl font-bold font-['Outfit']">
|
|
{dashboardData.stats.total_users}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Today's Stats */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<h3 className="font-semibold mb-4 font-['Outfit']">
|
|
Today's Performance
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Orders</p>
|
|
<p className="text-xl font-bold">
|
|
{dashboardData.stats.today_orders}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Revenue</p>
|
|
<p className="text-xl font-bold">
|
|
${(dashboardData.stats.today_revenue || 0).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<h3 className="font-semibold mb-4 font-['Outfit']">
|
|
Monthly Stats
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">Revenue</p>
|
|
<p className="text-xl font-bold">
|
|
$
|
|
{(dashboardData.stats.monthly_revenue || 0).toFixed(
|
|
2,
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">
|
|
Pending Bookings
|
|
</p>
|
|
<p className="text-xl font-bold">
|
|
{dashboardData.stats.pending_bookings}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Low Stock Alert */}
|
|
{dashboardData.low_stock_products.length > 0 && (
|
|
<div className="bg-card border border-orange-500/50 rounded-xl p-6">
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Low Stock Alert
|
|
</h3>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{dashboardData.low_stock_products.map((product) => (
|
|
<div
|
|
key={product.id}
|
|
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
|
>
|
|
<span>{product.name}</span>
|
|
<Badge variant="destructive">
|
|
{product.stock} left
|
|
</Badge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Orders */}
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<h3 className="font-semibold mb-4 font-['Outfit']">
|
|
Recent Orders
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{dashboardData.recent_orders.slice(0, 5).map((order) => (
|
|
<div
|
|
key={order.id}
|
|
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
|
>
|
|
<div>
|
|
<p className="font-medium font-mono text-sm">
|
|
{order.id.slice(0, 8)}...
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(order.created_at).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-semibold">
|
|
${order.total.toFixed(2)}
|
|
</span>
|
|
<Badge className={statusColors[order.status]}>
|
|
{order.status}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Products Tab */}
|
|
<TabsContent value="products">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Products Management
|
|
</h3>
|
|
<Dialog open={productDialog} onOpenChange={setProductDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
className="gap-2"
|
|
onClick={() => {
|
|
setEditingProduct(null);
|
|
setProductForm({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
category: "",
|
|
image_url: "",
|
|
stock: "",
|
|
brand: "",
|
|
images: [],
|
|
});
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Product
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto custom-scrollbar">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingProduct ? "Edit Product" : "Add Product"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={productForm.name}
|
|
onChange={(e) =>
|
|
setProductForm({
|
|
...productForm,
|
|
name: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description</Label>
|
|
<RichTextEditor
|
|
content={productForm.description}
|
|
onChange={(html) =>
|
|
setProductForm({
|
|
...productForm,
|
|
description: html,
|
|
})
|
|
}
|
|
placeholder="Enter product description with rich formatting..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Price</Label>
|
|
<Input
|
|
type="number"
|
|
value={productForm.price}
|
|
onChange={(e) =>
|
|
setProductForm({
|
|
...productForm,
|
|
price: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Stock</Label>
|
|
<Input
|
|
type="number"
|
|
value={productForm.stock}
|
|
onChange={(e) =>
|
|
setProductForm({
|
|
...productForm,
|
|
stock: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Category</Label>
|
|
<Select
|
|
value={productForm.category}
|
|
onValueChange={(v) =>
|
|
setProductForm({ ...productForm, category: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categories.map((cat) => (
|
|
<SelectItem
|
|
key={cat.id}
|
|
value={cat.name.toLowerCase()}
|
|
>
|
|
{cat.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Brand</Label>
|
|
<Input
|
|
value={productForm.brand}
|
|
onChange={(e) =>
|
|
setProductForm({
|
|
...productForm,
|
|
brand: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Separator className="my-4" />
|
|
<ImageUploadManager
|
|
images={productForm.images}
|
|
onChange={(images) =>
|
|
setProductForm({ ...productForm, images })
|
|
}
|
|
token={token}
|
|
/>
|
|
<div className="space-y-2">
|
|
<Label>Legacy Image URL (Optional)</Label>
|
|
<Input
|
|
placeholder="Or enter an image URL..."
|
|
value={productForm.image_url}
|
|
onChange={(e) =>
|
|
setProductForm({
|
|
...productForm,
|
|
image_url: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Use the image uploader above for best results. This
|
|
field is for backwards compatibility.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setProductDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleProductSubmit}>
|
|
{editingProduct ? "Update" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-3 px-2">Product</th>
|
|
<th className="text-left py-3 px-2">Category</th>
|
|
<th className="text-right py-3 px-2">Price</th>
|
|
<th className="text-right py-3 px-2">Stock</th>
|
|
<th className="text-center py-3 px-2">Status</th>
|
|
<th className="text-right py-3 px-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{products.map((product) => (
|
|
<tr
|
|
key={product.id}
|
|
className="border-b border-border/50"
|
|
>
|
|
<td className="py-3 px-2">
|
|
<div className="flex items-center gap-3">
|
|
<img
|
|
src={
|
|
product.images && product.images.length > 0
|
|
? (
|
|
product.images[0].url ||
|
|
product.images[0].image_url
|
|
).startsWith("/uploads")
|
|
? `${process.env.REACT_APP_BACKEND_URL}${
|
|
product.images[0].url ||
|
|
product.images[0].image_url
|
|
}`
|
|
: product.images[0].url ||
|
|
product.images[0].image_url
|
|
: product.image_url
|
|
}
|
|
alt={product.name}
|
|
className="w-10 h-10 rounded object-cover bg-muted"
|
|
onError={(e) => {
|
|
e.target.src =
|
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" /%3E%3Ccircle cx="8.5" cy="8.5" r="1.5" /%3E%3Cpath d="M21 15l-5-5L5 21" /%3E%3C/svg%3E';
|
|
}}
|
|
/>
|
|
<span className="font-medium">{product.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-2 capitalize">
|
|
{product.category}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
${product.price.toFixed(2)}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
{product.stock}
|
|
</td>
|
|
<td className="py-3 px-2 text-center">
|
|
<Badge
|
|
variant={
|
|
product.is_active ? "default" : "secondary"
|
|
}
|
|
>
|
|
{product.is_active ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditingProduct(product);
|
|
setProductForm({
|
|
name: product.name,
|
|
description: product.description,
|
|
price: product.price.toString(),
|
|
category: product.category,
|
|
image_url: product.image_url,
|
|
stock: product.stock.toString(),
|
|
brand: product.brand,
|
|
images:
|
|
product.images?.map(
|
|
(img) => img.url || img.image_url,
|
|
) || [],
|
|
});
|
|
setProductDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
onClick={() => {
|
|
setProductToDelete(product);
|
|
setDeleteConfirmOpen(true);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Services Tab */}
|
|
<TabsContent value="services">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Services Management
|
|
</h3>
|
|
<Dialog open={serviceDialog} onOpenChange={setServiceDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
className="gap-2"
|
|
onClick={() => {
|
|
setEditingService(null);
|
|
setServiceForm({
|
|
name: "",
|
|
description: "",
|
|
price: "",
|
|
duration: "",
|
|
image_url: "",
|
|
category: "",
|
|
images: [],
|
|
});
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Service
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto custom-scrollbar">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingService ? "Edit Service" : "Add Service"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={serviceForm.name}
|
|
onChange={(e) =>
|
|
setServiceForm({
|
|
...serviceForm,
|
|
name: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Category</Label>
|
|
<Select
|
|
value={serviceForm.category}
|
|
onValueChange={(v) =>
|
|
setServiceForm({ ...serviceForm, category: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="repair">Repair</SelectItem>
|
|
<SelectItem value="data">
|
|
Data Recovery
|
|
</SelectItem>
|
|
<SelectItem value="software">Software</SelectItem>
|
|
<SelectItem value="upgrade">Upgrade</SelectItem>
|
|
<SelectItem value="setup">Setup</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>Price</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={serviceForm.price}
|
|
onChange={(e) =>
|
|
setServiceForm({
|
|
...serviceForm,
|
|
price: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Duration</Label>
|
|
<Input
|
|
value={serviceForm.duration}
|
|
onChange={(e) =>
|
|
setServiceForm({
|
|
...serviceForm,
|
|
duration: e.target.value,
|
|
})
|
|
}
|
|
placeholder="e.g. 1-2 hours"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description</Label>
|
|
<div className="border rounded-lg">
|
|
<RichTextEditor
|
|
content={serviceForm.description}
|
|
onChange={(html) =>
|
|
setServiceForm({
|
|
...serviceForm,
|
|
description: html,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Separator className="my-4" />
|
|
<ImageUploadManager
|
|
images={serviceForm.images}
|
|
onChange={(images) =>
|
|
setServiceForm({ ...serviceForm, images })
|
|
}
|
|
token={token}
|
|
/>
|
|
<div className="space-y-2">
|
|
<Label>Legacy Image URL (Optional)</Label>
|
|
<Input
|
|
placeholder="Or enter an image URL..."
|
|
value={serviceForm.image_url}
|
|
onChange={(e) =>
|
|
setServiceForm({
|
|
...serviceForm,
|
|
image_url: e.target.value,
|
|
})
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Use the image uploader above for best results. This
|
|
field is for backwards compatibility.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setServiceDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleServiceSubmit}>
|
|
{editingService ? "Update" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-3 px-2">Service</th>
|
|
<th className="text-left py-3 px-2">Category</th>
|
|
<th className="text-left py-3 px-2">Duration</th>
|
|
<th className="text-right py-3 px-2">Price</th>
|
|
<th className="text-center py-3 px-2">Status</th>
|
|
<th className="text-right py-3 px-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{services.map((service) => (
|
|
<tr
|
|
key={service.id}
|
|
className="border-b border-border/50"
|
|
>
|
|
<td className="py-3 px-2">
|
|
<div className="flex items-center gap-3">
|
|
<img
|
|
src={
|
|
service.images && service.images.length > 0
|
|
? (
|
|
service.images[0].url ||
|
|
service.images[0].image_url
|
|
).startsWith("/uploads")
|
|
? `${process.env.REACT_APP_BACKEND_URL}${
|
|
service.images[0].url ||
|
|
service.images[0].image_url
|
|
}`
|
|
: service.images[0].url ||
|
|
service.images[0].image_url
|
|
: service.image_url
|
|
}
|
|
alt={service.name}
|
|
className="w-10 h-10 rounded object-cover bg-muted"
|
|
onError={(e) => {
|
|
e.target.src =
|
|
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"%3E%3Crect x="3" y="3" width="18" height="18" rx="2" /%3E%3Ccircle cx="8.5" cy="8.5" r="1.5" /%3E%3Cpath d="M21 15l-5-5L5 21" /%3E%3C/svg%3E';
|
|
}}
|
|
/>
|
|
<span className="font-medium">{service.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-2 capitalize">
|
|
{service.category}
|
|
</td>
|
|
<td className="py-3 px-2">{service.duration}</td>
|
|
<td className="py-3 px-2 text-right">
|
|
${service.price.toFixed(2)}
|
|
</td>
|
|
<td className="py-3 px-2 text-center">
|
|
<Badge
|
|
variant={
|
|
service.is_active ? "default" : "secondary"
|
|
}
|
|
>
|
|
{service.is_active ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditingService(service);
|
|
setServiceForm({
|
|
name: service.name,
|
|
description: service.description,
|
|
price: service.price.toString(),
|
|
duration: service.duration,
|
|
image_url: service.image_url || "",
|
|
category: service.category,
|
|
images:
|
|
service.images?.map(
|
|
(img) => img.url || img.image_url,
|
|
) || [],
|
|
});
|
|
setServiceDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
onClick={() => handleDeleteService(service.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Orders Tab */}
|
|
<TabsContent value="orders">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Orders Management
|
|
</h3>
|
|
<Select
|
|
value={orderStatusFilter}
|
|
onValueChange={(v) => {
|
|
setOrderStatusFilter(v);
|
|
setTimeout(fetchOrders, 100);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Filter by status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Orders</SelectItem>
|
|
<SelectItem value="pending">Pending</SelectItem>
|
|
<SelectItem value="processing">Processing</SelectItem>
|
|
<SelectItem value="layaway">Layaway</SelectItem>
|
|
<SelectItem value="shipped">Shipped</SelectItem>
|
|
<SelectItem value="delivered">Delivered</SelectItem>
|
|
<SelectItem value="cancelled">Cancelled</SelectItem>
|
|
<SelectItem value="refunded">Refunded</SelectItem>
|
|
<SelectItem value="on_hold">On Hold</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
{orders.map((order) => (
|
|
<div
|
|
key={order.id}
|
|
className="border border-border rounded-lg p-4"
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-4">
|
|
<div className="flex flex-wrap gap-4">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Order ID
|
|
</p>
|
|
<p className="font-mono text-sm">
|
|
{order.id.slice(0, 8)}...
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Customer
|
|
</p>
|
|
<p className="text-sm">{order.user_name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Date</p>
|
|
<p className="text-sm">
|
|
{new Date(order.created_at).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Total</p>
|
|
<p className="font-semibold">
|
|
${order.total.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge className={statusColors[order.status]}>
|
|
{order.status}
|
|
</Badge>
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setSelectedOrder(order.id);
|
|
setNewStatus(order.status);
|
|
setTrackingNumber(order.tracking_number || "");
|
|
}}
|
|
>
|
|
Update Status
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Update Order Status</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>New Status</Label>
|
|
<Select
|
|
value={newStatus}
|
|
onValueChange={setNewStatus}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="pending">
|
|
Pending
|
|
</SelectItem>
|
|
<SelectItem value="processing">
|
|
Processing
|
|
</SelectItem>
|
|
<SelectItem value="layaway">
|
|
Layaway
|
|
</SelectItem>
|
|
<SelectItem value="shipped">
|
|
Shipped
|
|
</SelectItem>
|
|
<SelectItem value="delivered">
|
|
Delivered
|
|
</SelectItem>
|
|
<SelectItem value="cancelled">
|
|
Cancelled
|
|
</SelectItem>
|
|
<SelectItem value="refunded">
|
|
Refunded
|
|
</SelectItem>
|
|
<SelectItem value="on_hold">
|
|
On Hold
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Tracking Number (optional)</Label>
|
|
<Input
|
|
value={trackingNumber}
|
|
onChange={(e) =>
|
|
setTrackingNumber(e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Notes</Label>
|
|
<Textarea
|
|
value={statusNotes}
|
|
onChange={(e) =>
|
|
setStatusNotes(e.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={handleUpdateOrderStatus}>
|
|
Update
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Order Items */}
|
|
<div className="text-sm text-muted-foreground">
|
|
{order.items.map((item) => (
|
|
<span key={item.id} className="mr-4">
|
|
{item.product_name} x{item.quantity}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Inventory Tab */}
|
|
<TabsContent value="inventory">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Inventory Management
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
className="gap-2"
|
|
onClick={() => handleExportCSV("inventory")}
|
|
>
|
|
<FileSpreadsheet className="h-4 w-4" />
|
|
Export CSV
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="gap-2"
|
|
onClick={() => handleExportPDF("inventory")}
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
Export PDF
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search products..."
|
|
value={inventorySearch}
|
|
onChange={(e) => {
|
|
setInventorySearch(e.target.value);
|
|
setInventoryCurrentPage(1);
|
|
}}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
<Select
|
|
value={inventoryCategory}
|
|
onValueChange={(v) => {
|
|
setInventoryCategory(v);
|
|
setInventoryCurrentPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Filter by category" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Categories</SelectItem>
|
|
{categories.map((cat) => (
|
|
<SelectItem key={cat.id} value={cat.name.toLowerCase()}>
|
|
{cat.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={inventoryItemsPerPage.toString()}
|
|
onValueChange={(v) => {
|
|
setInventoryItemsPerPage(parseInt(v));
|
|
setInventoryCurrentPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Items per page" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10 per page</SelectItem>
|
|
<SelectItem value="20">20 per page</SelectItem>
|
|
<SelectItem value="50">50 per page</SelectItem>
|
|
<SelectItem value="100">100 per page</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-3 px-2">Product</th>
|
|
<th className="text-left py-3 px-2">Category</th>
|
|
<th className="text-right py-3 px-2">Price</th>
|
|
<th className="text-right py-3 px-2">Stock</th>
|
|
<th className="text-right py-3 px-2">Threshold</th>
|
|
<th className="text-center py-3 px-2">Active</th>
|
|
<th className="text-right py-3 px-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(() => {
|
|
// Filter inventory
|
|
let filteredInventory = inventory.filter((product) => {
|
|
const matchesSearch =
|
|
inventorySearch === "" ||
|
|
product.name
|
|
.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase()) ||
|
|
product.brand
|
|
?.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase());
|
|
const matchesCategory =
|
|
inventoryCategory === "all" ||
|
|
product.category === inventoryCategory;
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
// Pagination
|
|
const totalPages = Math.ceil(
|
|
filteredInventory.length / inventoryItemsPerPage,
|
|
);
|
|
const startIndex =
|
|
(inventoryCurrentPage - 1) * inventoryItemsPerPage;
|
|
const endIndex = startIndex + inventoryItemsPerPage;
|
|
const paginatedInventory = filteredInventory.slice(
|
|
startIndex,
|
|
endIndex,
|
|
);
|
|
|
|
return paginatedInventory.map((product) => (
|
|
<tr
|
|
key={product.id}
|
|
className="border-b border-border/50"
|
|
>
|
|
<td className="py-3 px-2 font-medium">
|
|
{product.name}
|
|
</td>
|
|
<td className="py-3 px-2 capitalize">
|
|
{product.category}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
${product.price?.toFixed(2) || "0.00"}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
<span
|
|
className={
|
|
product.is_low_stock
|
|
? "text-destructive font-semibold"
|
|
: ""
|
|
}
|
|
>
|
|
{product.stock}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
{product.low_stock_threshold}
|
|
</td>
|
|
<td className="py-3 px-2 text-center">
|
|
<Button
|
|
size="sm"
|
|
variant={
|
|
product.is_active ? "default" : "secondary"
|
|
}
|
|
onClick={() => handleToggleActive(product)}
|
|
className="gap-2"
|
|
>
|
|
{product.is_active ? "Active" : "Inactive"}
|
|
</Button>
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
<Dialog
|
|
open={
|
|
adjustDialog && adjustProduct?.id === product.id
|
|
}
|
|
onOpenChange={(open) => {
|
|
setAdjustDialog(open);
|
|
if (!open) setAdjustProduct(null);
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setAdjustProduct(product);
|
|
setAdjustDialog(true);
|
|
}}
|
|
>
|
|
Adjust
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Adjust Inventory</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<p className="text-sm">
|
|
Current stock:{" "}
|
|
<strong>{adjustProduct?.stock}</strong>
|
|
</p>
|
|
<div className="space-y-2">
|
|
<Label>Quantity Change (+/-)</Label>
|
|
<Input
|
|
type="number"
|
|
value={adjustQty}
|
|
onChange={(e) =>
|
|
setAdjustQty(
|
|
parseInt(e.target.value) || 0,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Notes</Label>
|
|
<Textarea
|
|
value={adjustNotes}
|
|
onChange={(e) =>
|
|
setAdjustNotes(e.target.value)
|
|
}
|
|
placeholder="Reason for adjustment..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button onClick={handleAdjustInventory}>
|
|
Apply
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</td>
|
|
</tr>
|
|
));
|
|
})()}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination Controls */}
|
|
<div className="flex items-center justify-between mt-6 pt-6 border-t border-border">
|
|
<div className="text-sm text-muted-foreground">
|
|
{(() => {
|
|
const filtered = inventory.filter((p) => {
|
|
const matchesSearch =
|
|
inventorySearch === "" ||
|
|
p.name
|
|
.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase()) ||
|
|
p.brand
|
|
?.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase());
|
|
const matchesCategory =
|
|
inventoryCategory === "all" ||
|
|
p.category === inventoryCategory;
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
const start = Math.min(
|
|
(inventoryCurrentPage - 1) * inventoryItemsPerPage + 1,
|
|
filtered.length,
|
|
);
|
|
const end = Math.min(
|
|
inventoryCurrentPage * inventoryItemsPerPage,
|
|
filtered.length,
|
|
);
|
|
return `Showing ${
|
|
filtered.length === 0 ? 0 : start
|
|
} - ${end} of ${filtered.length} products`;
|
|
})()}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setInventoryCurrentPage((prev) => Math.max(1, prev - 1))
|
|
}
|
|
disabled={inventoryCurrentPage === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<div className="text-sm">
|
|
Page {inventoryCurrentPage} of{" "}
|
|
{Math.max(
|
|
1,
|
|
Math.ceil(
|
|
inventory.filter((p) => {
|
|
const matchesSearch =
|
|
inventorySearch === "" ||
|
|
p.name
|
|
.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase()) ||
|
|
p.brand
|
|
?.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase());
|
|
const matchesCategory =
|
|
inventoryCategory === "all" ||
|
|
p.category === inventoryCategory;
|
|
return matchesSearch && matchesCategory;
|
|
}).length / inventoryItemsPerPage,
|
|
),
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setInventoryCurrentPage((prev) => prev + 1)}
|
|
disabled={
|
|
inventoryCurrentPage >=
|
|
Math.ceil(
|
|
inventory.filter((p) => {
|
|
const matchesSearch =
|
|
inventorySearch === "" ||
|
|
p.name
|
|
.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase()) ||
|
|
p.brand
|
|
?.toLowerCase()
|
|
.includes(inventorySearch.toLowerCase());
|
|
const matchesCategory =
|
|
inventoryCategory === "all" ||
|
|
p.category === inventoryCategory;
|
|
return matchesSearch && matchesCategory;
|
|
}).length / inventoryItemsPerPage,
|
|
)
|
|
}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Bookings Tab */}
|
|
<TabsContent value="bookings">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<h3 className="font-semibold font-['Outfit'] mb-6">
|
|
Service Bookings
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{bookings.length === 0 ? (
|
|
<p className="text-muted-foreground text-center py-8">
|
|
No bookings yet
|
|
</p>
|
|
) : (
|
|
bookings.map((booking) => (
|
|
<div
|
|
key={booking.id}
|
|
className="border border-border rounded-lg p-4"
|
|
>
|
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<div className="flex flex-wrap gap-4 mb-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Service
|
|
</p>
|
|
<p className="font-medium">
|
|
{booking.service_name}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Customer
|
|
</p>
|
|
<p className="text-sm">{booking.name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Email
|
|
</p>
|
|
<p className="text-sm">{booking.email}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Phone
|
|
</p>
|
|
<p className="text-sm">{booking.phone}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Preferred Date
|
|
</p>
|
|
<p className="text-sm">
|
|
{booking.preferred_date}
|
|
</p>
|
|
</div>
|
|
{booking.completed_at && (
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Completed
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
{new Date(
|
|
booking.completed_at,
|
|
).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{booking.notes && (
|
|
<p className="text-sm text-muted-foreground mb-2">
|
|
<span className="font-medium">Notes:</span>{" "}
|
|
{booking.notes}
|
|
</p>
|
|
)}
|
|
{booking.diagnosis && (
|
|
<p className="text-sm text-muted-foreground mb-1">
|
|
<span className="font-medium">Diagnosis:</span>{" "}
|
|
{booking.diagnosis}
|
|
</p>
|
|
)}
|
|
{booking.work_performed && (
|
|
<p className="text-sm text-muted-foreground">
|
|
<span className="font-medium">Work Done:</span>{" "}
|
|
{booking.work_performed}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Badge
|
|
variant={
|
|
booking.status === "completed"
|
|
? "default"
|
|
: booking.status === "in-progress"
|
|
? "secondary"
|
|
: "outline"
|
|
}
|
|
className={
|
|
booking.status === "completed"
|
|
? "bg-green-500"
|
|
: ""
|
|
}
|
|
>
|
|
{booking.status === "completed" && (
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
|
)}
|
|
{booking.status === "in-progress" && (
|
|
<Clock className="h-3 w-3 mr-1" />
|
|
)}
|
|
{booking.status}
|
|
</Badge>
|
|
{booking.paid && (
|
|
<Badge className="bg-blue-500">
|
|
<DollarSign className="h-3 w-3 mr-1" />
|
|
Paid
|
|
</Badge>
|
|
)}
|
|
<div className="flex gap-2 mt-2">
|
|
{booking.status !== "completed" && (
|
|
<>
|
|
{booking.status === "pending" && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() =>
|
|
handleUpdateBookingStatus(
|
|
booking.id,
|
|
"in-progress",
|
|
)
|
|
}
|
|
>
|
|
<Clock className="h-4 w-4 mr-1" />
|
|
Start
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
className="bg-green-600 hover:bg-green-700"
|
|
onClick={() =>
|
|
handleOpenCompleteDialog(booking)
|
|
}
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-1" />
|
|
Complete
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handlePrintReceipt(booking)}
|
|
>
|
|
<Printer className="h-4 w-4 mr-1" />
|
|
Receipt
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => handleDeleteBooking(booking)}
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Categories Tab */}
|
|
<TabsContent value="categories">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Categories Management
|
|
</h3>
|
|
<Dialog open={categoryDialog} onOpenChange={setCategoryDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
className="gap-2"
|
|
onClick={() => {
|
|
setEditingCategory(null);
|
|
setCategoryForm({ name: "", description: "" });
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Category
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingCategory ? "Edit Category" : "Add Category"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={categoryForm.name}
|
|
onChange={(e) =>
|
|
setCategoryForm({
|
|
...categoryForm,
|
|
name: e.target.value,
|
|
})
|
|
}
|
|
placeholder="e.g., Phones, Laptops"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Description (Optional)</Label>
|
|
<Textarea
|
|
value={categoryForm.description}
|
|
onChange={(e) =>
|
|
setCategoryForm({
|
|
...categoryForm,
|
|
description: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Brief description of the category"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setCategoryDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCategorySubmit}>
|
|
{editingCategory ? "Update" : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-3 px-2">Name</th>
|
|
<th className="text-left py-3 px-2">Description</th>
|
|
<th className="text-right py-3 px-2">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{categories.map((category) => (
|
|
<tr
|
|
key={category.id}
|
|
className="border-b border-border/50"
|
|
>
|
|
<td className="py-3 px-2">
|
|
<span className="font-medium">{category.name}</span>
|
|
</td>
|
|
<td className="py-3 px-2">
|
|
<span className="text-muted-foreground text-sm">
|
|
{category.description || "No description"}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditingCategory(category);
|
|
setCategoryForm({
|
|
name: category.name,
|
|
description: category.description || "",
|
|
});
|
|
setCategoryDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-destructive"
|
|
onClick={() => handleDeleteCategory(category.id)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{categories.length === 0 && (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
No categories yet. Create one to get started.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Reports Tab */}
|
|
{/* Users Tab */}
|
|
<TabsContent value="users">
|
|
<div className="space-y-6">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
User Management
|
|
</h3>
|
|
<Dialog open={userDialog} onOpenChange={setUserDialog}>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
onClick={() => {
|
|
setEditingUser(null);
|
|
setUserForm({
|
|
name: "",
|
|
email: "",
|
|
password: "",
|
|
role: "user",
|
|
is_active: true,
|
|
});
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add User
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingUser ? "Edit User" : "Add New User"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>Name</Label>
|
|
<Input
|
|
value={userForm.name}
|
|
onChange={(e) =>
|
|
setUserForm({ ...userForm, name: e.target.value })
|
|
}
|
|
placeholder="Enter user name"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Email</Label>
|
|
<Input
|
|
type="email"
|
|
value={userForm.email}
|
|
onChange={(e) =>
|
|
setUserForm({
|
|
...userForm,
|
|
email: e.target.value,
|
|
})
|
|
}
|
|
placeholder="user@example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>
|
|
Password{" "}
|
|
{editingUser && "(leave blank to keep current)"}
|
|
</Label>
|
|
<Input
|
|
type="password"
|
|
value={userForm.password}
|
|
onChange={(e) =>
|
|
setUserForm({
|
|
...userForm,
|
|
password: e.target.value,
|
|
})
|
|
}
|
|
placeholder={
|
|
editingUser
|
|
? "Leave blank to keep current password"
|
|
: "Enter password"
|
|
}
|
|
/>
|
|
{editingUser && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Only enter a new password if you want to change it
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label>Role</Label>
|
|
<Select
|
|
value={userForm.role}
|
|
onValueChange={(value) =>
|
|
setUserForm({ ...userForm, role: value })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="employee">Employee</SelectItem>
|
|
<SelectItem value="accountant">
|
|
Accountant
|
|
</SelectItem>
|
|
<SelectItem value="sales_manager">
|
|
Sales Manager
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="is_active"
|
|
checked={userForm.is_active}
|
|
onChange={(e) =>
|
|
setUserForm({
|
|
...userForm,
|
|
is_active: e.target.checked,
|
|
})
|
|
}
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
/>
|
|
<Label htmlFor="is_active">Active Account</Label>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setUserDialog(false);
|
|
setEditingUser(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleUserSubmit}>
|
|
{editingUser ? "Update" : "Create"} User
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
|
<div className="flex-1">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search by name or email..."
|
|
value={userSearch}
|
|
onChange={(e) => setUserSearch(e.target.value)}
|
|
className="pl-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<Select
|
|
value={userRoleFilter || undefined}
|
|
onValueChange={(value) =>
|
|
setUserRoleFilter(value === "all" ? "" : value)
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full md:w-[180px]">
|
|
<SelectValue placeholder="All Roles" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Roles</SelectItem>
|
|
<SelectItem value="user">User</SelectItem>
|
|
<SelectItem value="admin">Admin</SelectItem>
|
|
<SelectItem value="employee">Employee</SelectItem>
|
|
<SelectItem value="accountant">Accountant</SelectItem>
|
|
<SelectItem value="sales_manager">
|
|
Sales Manager
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select
|
|
value={userStatusFilter || undefined}
|
|
onValueChange={(value) =>
|
|
setUserStatusFilter(value === "all" ? "" : value)
|
|
}
|
|
>
|
|
<SelectTrigger className="w-full md:w-[180px]">
|
|
<SelectValue placeholder="All Status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Status</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
onClick={fetchUsers}
|
|
className="gap-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4" />
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Users Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="border-b">
|
|
<tr>
|
|
<th className="text-left p-3 font-medium">Name</th>
|
|
<th className="text-left p-3 font-medium">Email</th>
|
|
<th className="text-left p-3 font-medium">Role</th>
|
|
<th className="text-left p-3 font-medium">Status</th>
|
|
<th className="text-left p-3 font-medium">Created</th>
|
|
<th className="text-right p-3 font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((user) => (
|
|
<tr
|
|
key={user.id}
|
|
className="border-b hover:bg-muted/50"
|
|
>
|
|
<td className="p-3">{user.name}</td>
|
|
<td className="p-3">{user.email}</td>
|
|
<td className="p-3">
|
|
<Badge variant="outline" className="capitalize">
|
|
{user.role.replace("_", " ")}
|
|
</Badge>
|
|
</td>
|
|
<td className="p-3">
|
|
<Badge
|
|
variant={user.is_active ? "default" : "secondary"}
|
|
>
|
|
{user.is_active ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</td>
|
|
<td className="p-3">
|
|
{new Date(user.created_at).toLocaleDateString()}
|
|
</td>
|
|
<td className="p-3">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleToggleUserActive(user.id)}
|
|
title={
|
|
user.is_active ? "Deactivate" : "Activate"
|
|
}
|
|
>
|
|
{user.is_active ? "Deactivate" : "Activate"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setEditingUser(user);
|
|
setUserForm({
|
|
name: user.name,
|
|
email: user.email,
|
|
password: "",
|
|
role: user.role,
|
|
is_active: user.is_active,
|
|
});
|
|
setUserDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => {
|
|
showConfirmDialog(
|
|
"Delete User",
|
|
`Are you sure you want to delete ${user.name}? This action cannot be undone.`,
|
|
() => handleDeleteUser(user.id),
|
|
);
|
|
}}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4 mt-6 pt-6 border-t">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-muted-foreground">
|
|
Items per page:
|
|
</span>
|
|
<Select
|
|
value={usersPerPage.toString()}
|
|
onValueChange={(value) => {
|
|
setUsersPerPage(parseInt(value));
|
|
setCurrentUsersPage(1);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[80px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="10">10</SelectItem>
|
|
<SelectItem value="20">20</SelectItem>
|
|
<SelectItem value="50">50</SelectItem>
|
|
<SelectItem value="100">100</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setCurrentUsersPage((p) => Math.max(1, p - 1))
|
|
}
|
|
disabled={currentUsersPage === 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm text-muted-foreground px-4">
|
|
Page {currentUsersPage} of{" "}
|
|
{Math.ceil(usersTotal / usersPerPage) || 1}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
setCurrentUsersPage((p) =>
|
|
Math.min(Math.ceil(usersTotal / usersPerPage), p + 1),
|
|
)
|
|
}
|
|
disabled={
|
|
currentUsersPage >= Math.ceil(usersTotal / usersPerPage)
|
|
}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="reports">
|
|
<div className="space-y-6">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Sales Reports
|
|
</h3>
|
|
<div className="flex gap-2">
|
|
<Select
|
|
value={reportPeriod}
|
|
onValueChange={(v) => {
|
|
setReportPeriod(v);
|
|
setTimeout(fetchReports, 100);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-[150px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="daily">Daily</SelectItem>
|
|
<SelectItem value="weekly">Weekly</SelectItem>
|
|
<SelectItem value="monthly">Monthly</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="outline"
|
|
className="gap-2"
|
|
onClick={() => handleExportCSV("sales")}
|
|
>
|
|
<FileSpreadsheet className="h-4 w-4" />
|
|
CSV
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
className="gap-2"
|
|
onClick={() => handleExportPDF("sales")}
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
PDF
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{salesReport && (
|
|
<>
|
|
{/* Summary */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-muted rounded-lg p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Total Orders
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
{salesReport.summary.total_orders}
|
|
</p>
|
|
</div>
|
|
<div className="bg-muted rounded-lg p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Product Revenue
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
${salesReport.summary.total_revenue.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-muted rounded-lg p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Products Sold
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
{salesReport.summary.total_products_sold}
|
|
</p>
|
|
</div>
|
|
<div className="bg-muted rounded-lg p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Avg Order Value
|
|
</p>
|
|
<p className="text-2xl font-bold">
|
|
${salesReport.summary.average_order_value.toFixed(2)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Service Revenue Summary */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
|
<p className="text-sm text-green-700 dark:text-green-400">
|
|
Services Booked
|
|
</p>
|
|
<p className="text-2xl font-bold text-green-800 dark:text-green-300">
|
|
{salesReport.summary.total_services_booked || 0}
|
|
</p>
|
|
</div>
|
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
|
<p className="text-sm text-green-700 dark:text-green-400">
|
|
Services Paid
|
|
</p>
|
|
<p className="text-2xl font-bold text-green-800 dark:text-green-300">
|
|
{salesReport.summary.total_services_paid || 0}
|
|
</p>
|
|
</div>
|
|
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
|
<p className="text-sm text-green-700 dark:text-green-400">
|
|
Service Revenue
|
|
</p>
|
|
<p className="text-2xl font-bold text-green-800 dark:text-green-300">
|
|
$
|
|
{(
|
|
salesReport.summary.total_service_revenue || 0
|
|
).toFixed(2)}
|
|
</p>
|
|
</div>
|
|
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
|
<p className="text-sm text-blue-700 dark:text-blue-400">
|
|
Combined Revenue
|
|
</p>
|
|
<p className="text-2xl font-bold text-blue-800 dark:text-blue-300">
|
|
$
|
|
{(salesReport.summary.combined_revenue || 0).toFixed(
|
|
2,
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Data Table */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border">
|
|
<th className="text-left py-3 px-2">Period</th>
|
|
<th className="text-right py-3 px-2">Orders</th>
|
|
<th className="text-right py-3 px-2">
|
|
Product Rev
|
|
</th>
|
|
<th className="text-right py-3 px-2">Services</th>
|
|
<th className="text-right py-3 px-2">Paid</th>
|
|
<th className="text-right py-3 px-2">
|
|
Service Rev
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{salesReport.data.map((row, idx) => (
|
|
<tr key={idx} className="border-b border-border/50">
|
|
<td className="py-3 px-2">{row.period}</td>
|
|
<td className="py-3 px-2 text-right">
|
|
{row.orders}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
${row.revenue.toFixed(2)}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
{row.services_booked || 0}
|
|
</td>
|
|
<td className="py-3 px-2 text-right">
|
|
{row.services_paid || 0}
|
|
</td>
|
|
<td className="py-3 px-2 text-right text-green-600">
|
|
${(row.service_revenue || 0).toFixed(2)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Media Tab */}
|
|
<TabsContent value="media">
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<MediaManager />
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* About Page Tab */}
|
|
<TabsContent value="about">
|
|
<div className="space-y-6">
|
|
{/* Hero/Story Content Section */}
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Page Content
|
|
</h3>
|
|
<Button
|
|
onClick={() => {
|
|
setAboutEditType("content");
|
|
setEditingAboutItem(null);
|
|
setAboutForm({
|
|
section: "hero",
|
|
title: "",
|
|
subtitle: "",
|
|
content: "",
|
|
image_url: "",
|
|
data: null,
|
|
display_order: 0,
|
|
is_active: true,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Content Section
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{aboutContent.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="flex items-center justify-between p-4 bg-muted rounded-lg"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{item.section}</Badge>
|
|
<h4 className="font-medium">
|
|
{item.title || "No title"}
|
|
</h4>
|
|
{!item.is_active && (
|
|
<Badge variant="secondary">Inactive</Badge>
|
|
)}
|
|
</div>
|
|
{item.subtitle && (
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
{item.subtitle}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setAboutEditType("content");
|
|
setEditingAboutItem(item);
|
|
setAboutForm({
|
|
section: item.section,
|
|
title: item.title || "",
|
|
subtitle: item.subtitle || "",
|
|
content: item.content || "",
|
|
image_url: item.image_url || "",
|
|
data: item.data,
|
|
display_order: item.display_order,
|
|
is_active: item.is_active,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleDeleteAboutItem(item.id, "content")
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{aboutContent.length === 0 && (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No content sections yet. Add your first section!
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Team Members Section */}
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Team Members
|
|
</h3>
|
|
<Button
|
|
onClick={() => {
|
|
setAboutEditType("team");
|
|
setEditingAboutItem(null);
|
|
setAboutForm({
|
|
name: "",
|
|
role: "",
|
|
bio: "",
|
|
image_url: "",
|
|
email: "",
|
|
linkedin: "",
|
|
display_order: 0,
|
|
is_active: true,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Team Member
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{teamMembers.map((member) => (
|
|
<div
|
|
key={member.id}
|
|
className="border border-border rounded-lg p-4 space-y-3"
|
|
>
|
|
{member.image_url && (
|
|
<img
|
|
src={member.image_url}
|
|
alt={member.name}
|
|
className="w-full h-48 object-cover rounded-lg"
|
|
/>
|
|
)}
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="font-medium">{member.name}</h4>
|
|
{!member.is_active && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Inactive
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
{member.role}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setAboutEditType("team");
|
|
setEditingAboutItem(member);
|
|
setAboutForm({
|
|
name: member.name,
|
|
role: member.role,
|
|
bio: member.bio || "",
|
|
image_url: member.image_url || "",
|
|
email: member.email || "",
|
|
linkedin: member.linkedin || "",
|
|
display_order: member.display_order,
|
|
is_active: member.is_active,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4 mr-1" />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleDeleteAboutItem(member.id, "team")
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{teamMembers.length === 0 && (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No team members yet. Add your first team member!
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Company Values Section */}
|
|
<div className="bg-card border border-border rounded-xl p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="font-semibold font-['Outfit']">
|
|
Company Values
|
|
</h3>
|
|
<Button
|
|
onClick={() => {
|
|
setAboutEditType("value");
|
|
setEditingAboutItem(null);
|
|
setAboutForm({
|
|
title: "",
|
|
description: "",
|
|
icon: "",
|
|
display_order: 0,
|
|
is_active: true,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Value
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{companyValues.map((value) => (
|
|
<div
|
|
key={value.id}
|
|
className="border border-border rounded-lg p-4 space-y-2"
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
{value.icon && (
|
|
<span className="text-2xl">{value.icon}</span>
|
|
)}
|
|
<h4 className="font-medium">{value.title}</h4>
|
|
{!value.is_active && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Inactive
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
{value.description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setAboutEditType("value");
|
|
setEditingAboutItem(value);
|
|
setAboutForm({
|
|
title: value.title,
|
|
description: value.description,
|
|
icon: value.icon,
|
|
display_order: value.display_order,
|
|
is_active: value.is_active,
|
|
});
|
|
setAboutDialog(true);
|
|
}}
|
|
>
|
|
<Edit className="h-4 w-4 mr-1" />
|
|
Edit
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
handleDeleteAboutItem(value.id, "value")
|
|
}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{companyValues.length === 0 && (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No company values yet. Add your first value!
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* About Page Dialog */}
|
|
<Dialog open={aboutDialog} onOpenChange={setAboutDialog}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingAboutItem ? "Edit" : "Add"}{" "}
|
|
{aboutEditType === "content" && "Content Section"}
|
|
{aboutEditType === "team" && "Team Member"}
|
|
{aboutEditType === "value" && "Company Value"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
{/* Content Section Form */}
|
|
{aboutEditType === "content" && (
|
|
<>
|
|
<div>
|
|
<Label>Section</Label>
|
|
<Select
|
|
value={aboutForm.section}
|
|
onValueChange={(v) =>
|
|
setAboutForm({ ...aboutForm, section: v })
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Select section" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hero">Hero</SelectItem>
|
|
<SelectItem value="story">Our Story</SelectItem>
|
|
<SelectItem value="stats">Statistics</SelectItem>
|
|
<SelectItem value="mission">Mission</SelectItem>
|
|
<SelectItem value="vision">Vision</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label>Title</Label>
|
|
<Input
|
|
value={aboutForm.title}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, title: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Subtitle</Label>
|
|
<Input
|
|
value={aboutForm.subtitle}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, subtitle: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Content (HTML)</Label>
|
|
<RichTextEditor
|
|
value={aboutForm.content}
|
|
onChange={(html) =>
|
|
setAboutForm({ ...aboutForm, content: html })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Image</Label>
|
|
<ImageUploadManager
|
|
images={aboutForm.image_url ? [aboutForm.image_url] : []}
|
|
onChange={(imgs) =>
|
|
setAboutForm({ ...aboutForm, image_url: imgs[0] || "" })
|
|
}
|
|
token={token}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Team Member Form */}
|
|
{aboutEditType === "team" && (
|
|
<>
|
|
<div>
|
|
<Label>Name*</Label>
|
|
<Input
|
|
value={aboutForm.name}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, name: e.target.value })
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Role*</Label>
|
|
<Input
|
|
value={aboutForm.role}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, role: e.target.value })
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Bio (HTML)</Label>
|
|
<RichTextEditor
|
|
value={aboutForm.bio}
|
|
onChange={(html) =>
|
|
setAboutForm({ ...aboutForm, bio: html })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Email</Label>
|
|
<Input
|
|
type="email"
|
|
value={aboutForm.email}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, email: e.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>LinkedIn URL</Label>
|
|
<Input
|
|
value={aboutForm.linkedin}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, linkedin: e.target.value })
|
|
}
|
|
placeholder="https://linkedin.com/in/..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Profile Image</Label>
|
|
<ImageUploadManager
|
|
images={aboutForm.image_url ? [aboutForm.image_url] : []}
|
|
onChange={(imgs) =>
|
|
setAboutForm({ ...aboutForm, image_url: imgs[0] || "" })
|
|
}
|
|
token={token}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Company Value Form */}
|
|
{aboutEditType === "value" && (
|
|
<>
|
|
<div>
|
|
<Label>Title*</Label>
|
|
<Input
|
|
value={aboutForm.title}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, title: e.target.value })
|
|
}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Description*</Label>
|
|
<Textarea
|
|
value={aboutForm.description}
|
|
onChange={(e) =>
|
|
setAboutForm({
|
|
...aboutForm,
|
|
description: e.target.value,
|
|
})
|
|
}
|
|
rows={3}
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label>Icon (emoji or icon name)</Label>
|
|
<Input
|
|
value={aboutForm.icon}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, icon: e.target.value })
|
|
}
|
|
placeholder="💡 or icon-name"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Common fields */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label>Display Order</Label>
|
|
<Input
|
|
type="number"
|
|
value={aboutForm.display_order}
|
|
onChange={(e) =>
|
|
setAboutForm({
|
|
...aboutForm,
|
|
display_order: parseInt(e.target.value) || 0,
|
|
})
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2 pt-6">
|
|
<input
|
|
type="checkbox"
|
|
checked={aboutForm.is_active}
|
|
onChange={(e) =>
|
|
setAboutForm({ ...aboutForm, is_active: e.target.checked })
|
|
}
|
|
className="h-4 w-4"
|
|
/>
|
|
<Label>Active</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAboutDialog(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleSaveAboutItem}>Save</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Product</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete{" "}
|
|
<span className="font-semibold text-foreground">
|
|
"{productToDelete?.name}"
|
|
</span>
|
|
? This action cannot be undone. The product will be removed from
|
|
the database and will no longer be available to customers.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setProductToDelete(null)}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleDeleteProduct}
|
|
className="bg-destructive hover:bg-destructive/90"
|
|
>
|
|
Delete Product
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Generic Confirmation Dialog */}
|
|
<AlertDialog
|
|
open={confirmDialog.open}
|
|
onOpenChange={(open) => !open && handleCancelConfirm()}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{confirmDialog.title}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{confirmDialog.message}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={handleCancelConfirm}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleConfirmAction}
|
|
className="bg-destructive hover:bg-destructive/90"
|
|
>
|
|
Confirm
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Booking Complete Dialog */}
|
|
<Dialog
|
|
open={bookingCompleteDialog}
|
|
onOpenChange={setBookingCompleteDialog}
|
|
>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{isEditMode ? "Edit Booking Details" : "Complete Service"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
|
<div className="bg-muted p-3 rounded-lg">
|
|
<p className="font-medium">{selectedBooking?.service_name}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
Customer: {selectedBooking?.name}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Device Information Section */}
|
|
<div className="border border-border rounded-lg p-3 space-y-3">
|
|
<h4 className="font-medium text-sm">Device Information</h4>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Device Model</Label>
|
|
<Input
|
|
value={bookingCompleteForm.device_model}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
device_model: e.target.value,
|
|
})
|
|
}
|
|
placeholder="e.g., Dell Latitude 5520"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Screen Size</Label>
|
|
<Input
|
|
value={bookingCompleteForm.screen_size}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
screen_size: e.target.value,
|
|
})
|
|
}
|
|
placeholder="e.g., 15-inch"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Serial Number</Label>
|
|
<Input
|
|
value={bookingCompleteForm.serial_number}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
serial_number: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Enter serial number"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label className="text-xs">Product Number</Label>
|
|
<Input
|
|
value={bookingCompleteForm.product_number}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
product_number: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Enter product number"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Diagnosis / Issue Found</Label>
|
|
<Textarea
|
|
value={bookingCompleteForm.diagnosis}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
diagnosis: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Describe what was wrong with the device..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Work Performed</Label>
|
|
<Textarea
|
|
value={bookingCompleteForm.work_performed}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
work_performed: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Describe what was done to fix it..."
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Technician Notes (Internal)</Label>
|
|
<Textarea
|
|
value={bookingCompleteForm.technician_notes}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
technician_notes: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Any internal notes (not shown on receipt)..."
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Final Service Cost ($)</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={bookingCompleteForm.service_cost}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
service_cost: e.target.value,
|
|
})
|
|
}
|
|
placeholder="Leave blank to use base service price"
|
|
/>
|
|
</div>
|
|
{!isEditMode && (
|
|
<div className="flex items-center space-x-2 pt-2">
|
|
<input
|
|
type="checkbox"
|
|
id="paid-checkbox"
|
|
checked={bookingCompleteForm.paid}
|
|
onChange={(e) =>
|
|
setBookingCompleteForm({
|
|
...bookingCompleteForm,
|
|
paid: e.target.checked,
|
|
})
|
|
}
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
/>
|
|
<Label
|
|
htmlFor="paid-checkbox"
|
|
className="text-sm font-medium cursor-pointer"
|
|
>
|
|
Payment Received
|
|
</Label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setBookingCompleteDialog(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
{isEditMode ? (
|
|
<Button onClick={handleSaveBooking}>Save Changes</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleCompleteBooking}
|
|
className="bg-green-600 hover:bg-green-700"
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-2" />
|
|
Mark as Completed
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Receipt Dialog */}
|
|
<Dialog open={showReceiptDialog} onOpenChange={setShowReceiptDialog}>
|
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Service Receipt</DialogTitle>
|
|
</DialogHeader>
|
|
{receiptData && (
|
|
<>
|
|
<div
|
|
ref={receiptRef}
|
|
className="bg-white text-black p-6 rounded-lg print-receipt"
|
|
>
|
|
<style>{`
|
|
@media print {
|
|
.print-receipt { padding: 10px !important; }
|
|
.print-receipt .logo img { max-height: 50px !important; width: auto !important; }
|
|
.print-receipt .section { margin-bottom: 12px !important; }
|
|
.print-receipt h2 { font-size: 16px !important; margin-bottom: 12px !important; }
|
|
.print-receipt h3 { font-size: 12px !important; }
|
|
.print-receipt .text-sm { font-size: 11px !important; }
|
|
.print-receipt .text-xs { font-size: 10px !important; }
|
|
}
|
|
`}</style>
|
|
<div className="receipt">
|
|
{/* Header */}
|
|
<div className="header text-center border-b-2 border-blue-600 pb-3 mb-4">
|
|
<div className="logo flex flex-col items-center mb-2">
|
|
<img
|
|
src="/logo.png"
|
|
alt="PromptTech Solutions"
|
|
className="h-12 max-h-12 w-auto mb-1"
|
|
style={{ maxWidth: "120px" }}
|
|
onError={(e) => {
|
|
e.target.style.display = "none";
|
|
}}
|
|
/>
|
|
<span className="text-xl font-bold text-blue-600">
|
|
PromptTech Solutions
|
|
</span>
|
|
</div>
|
|
<div className="company-info text-xs text-gray-600">
|
|
<p>{receiptData.company?.address}</p>
|
|
<p>
|
|
Phone: {receiptData.company?.phone} | Email:{" "}
|
|
{receiptData.company?.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Receipt Title */}
|
|
<h2 className="text-lg font-bold text-center mb-4">
|
|
SERVICE RECEIPT
|
|
</h2>
|
|
|
|
{/* Customer Info */}
|
|
<div className="section mb-4">
|
|
<h3 className="font-bold border-b pb-1 mb-2 text-sm">
|
|
Customer Information
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-1 text-sm">
|
|
<div>
|
|
<span className="font-medium text-gray-600">Name:</span>{" "}
|
|
{receiptData.name}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Phone:
|
|
</span>{" "}
|
|
{receiptData.phone}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Email:
|
|
</span>{" "}
|
|
{receiptData.email}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Date Booked:
|
|
</span>{" "}
|
|
{new Date(receiptData.created_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Service Info */}
|
|
<div className="section mb-4">
|
|
<h3 className="font-bold border-b pb-1 mb-2 text-sm">
|
|
Service Details
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-1 text-sm">
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Service:
|
|
</span>{" "}
|
|
{receiptData.service_name}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Preferred Date:
|
|
</span>{" "}
|
|
{receiptData.preferred_date}
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Status:
|
|
</span>{" "}
|
|
{receiptData.status}
|
|
</div>
|
|
{receiptData.completed_at && (
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Completed:
|
|
</span>{" "}
|
|
{new Date(
|
|
receiptData.completed_at,
|
|
).toLocaleDateString()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Device Information */}
|
|
{(receiptData.device_model ||
|
|
receiptData.serial_number ||
|
|
receiptData.product_number ||
|
|
receiptData.screen_size) && (
|
|
<div className="section mb-4">
|
|
<h3 className="font-bold border-b pb-1 mb-2 text-sm">
|
|
Device Information
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-1 text-sm">
|
|
{receiptData.device_model && (
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Model:
|
|
</span>{" "}
|
|
{receiptData.device_model}
|
|
</div>
|
|
)}
|
|
{receiptData.screen_size && (
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Screen Size:
|
|
</span>{" "}
|
|
{receiptData.screen_size}
|
|
</div>
|
|
)}
|
|
{receiptData.serial_number && (
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Serial #:
|
|
</span>{" "}
|
|
{receiptData.serial_number}
|
|
</div>
|
|
)}
|
|
{receiptData.product_number && (
|
|
<div>
|
|
<span className="font-medium text-gray-600">
|
|
Product #:
|
|
</span>{" "}
|
|
{receiptData.product_number}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Diagnosis & Work */}
|
|
{(receiptData.diagnosis || receiptData.work_performed) && (
|
|
<div className="section mb-4">
|
|
<h3 className="font-bold border-b pb-1 mb-2 text-sm">
|
|
Service Report
|
|
</h3>
|
|
{receiptData.diagnosis && (
|
|
<div className="bg-gray-50 p-2 rounded mb-2">
|
|
<p className="font-medium text-gray-600 mb-1">
|
|
Diagnosis:
|
|
</p>
|
|
<p className="text-sm">{receiptData.diagnosis}</p>
|
|
</div>
|
|
)}
|
|
{receiptData.work_performed && (
|
|
<div className="bg-gray-50 p-2 rounded">
|
|
<p className="font-medium text-gray-600 mb-1">
|
|
Work Performed:
|
|
</p>
|
|
<p className="text-sm">
|
|
{receiptData.work_performed}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
{receiptData.notes && (
|
|
<div className="section mb-4">
|
|
<h3 className="font-bold border-b pb-1 mb-2">
|
|
Customer Notes
|
|
</h3>
|
|
<p className="text-sm bg-gray-50 p-2 rounded">
|
|
{receiptData.notes}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Total */}
|
|
<div className="total text-lg font-bold text-right border-t-2 border-gray-800 pt-3 mt-4">
|
|
Total: ${receiptData.final_cost?.toFixed(2) || "0.00"}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="footer text-center mt-4 pt-3 border-t text-xs text-gray-500">
|
|
<p>Thank you for choosing PromptTech Solutions!</p>
|
|
<p>Questions? Contact us at {receiptData.company?.phone}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="mt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowReceiptDialog(false)}
|
|
>
|
|
Close
|
|
</Button>
|
|
<Button onClick={printReceipt}>
|
|
<Printer className="h-4 w-4 mr-2" />
|
|
Print / Save as PDF
|
|
</Button>
|
|
</DialogFooter>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AdminDashboard;
|