diff --git a/apps/web/package.json b/apps/web/package.json index 24c2c5ea..34ed9716 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,12 +10,12 @@ "preview": "vite preview" }, "dependencies": { - "@dataconnect/generated": "link:src/dataconnect-generated", "@firebase/analytics": "^0.10.19", "@firebase/data-connect": "^0.3.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/themes": "^3.2.1", "@reduxjs/toolkit": "^2.11.2", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 73faff90..09332592 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -5,17 +5,12 @@ settings: excludeLinksFromLockfile: false overrides: - '@firebasegen/example-connector': link:src/dataconnect-generated dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated - '@dataconnect/generated': link:src/dataconnect-generated importers: .: dependencies: - '@dataconnect/generated': - specifier: link:src/dataconnect-generated - version: link:src/dataconnect-generated '@firebase/analytics': specifier: ^0.10.19 version: 0.10.19(@firebase/app@0.14.7) @@ -31,6 +26,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-switch': + specifier: ^1.2.6 + version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-tabs': specifier: ^1.1.13 version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1510,66 +1508,79 @@ packages: resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.0': resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.0': resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.0': resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.0': resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.0': resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.0': resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.0': resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.0': resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.0': resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.0': resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.0': resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.0': resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.0': resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} @@ -1645,24 +1656,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -2390,24 +2405,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/apps/web/pnpm-workspace.yaml b/apps/web/pnpm-workspace.yaml index 98e4ea6b..62ff1aa4 100644 --- a/apps/web/pnpm-workspace.yaml +++ b/apps/web/pnpm-workspace.yaml @@ -1,4 +1,3 @@ overrides: - '@dataconnect/generated': link:src/dataconnect-generated - '@firebasegen/example-connector': link:src/dataconnect-generated + dataconnect-generated: link:../../../../../AppData/Local/pnpm/global/5/node_modules/src/dataconnect-generated diff --git a/apps/web/src/common/components/ui/alert.tsx b/apps/web/src/common/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/apps/web/src/common/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web/src/common/components/ui/switch.tsx b/apps/web/src/common/components/ui/switch.tsx new file mode 100644 index 00000000..726f9788 --- /dev/null +++ b/apps/web/src/common/components/ui/switch.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + + + ) +) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/apps/web/src/common/components/ui/table.tsx b/apps/web/src/common/components/ui/table.tsx new file mode 100644 index 00000000..a82b1f48 --- /dev/null +++ b/apps/web/src/common/components/ui/table.tsx @@ -0,0 +1,120 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef< + HTMLTableElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+ + +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef< + HTMLTableSectionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + tr]:last:border-b-0", + className + )} + {...props} + /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef< + HTMLTableCellElement, + React.ThHTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef< + HTMLTableCellElement, + React.TdHTMLAttributes +>(({ className, ...props }, ref) => ( + +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef< + HTMLTableCaptionElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/apps/web/src/features/operations/orders/OrderDetail.tsx b/apps/web/src/features/operations/orders/OrderDetail.tsx new file mode 100644 index 00000000..9a9db41a --- /dev/null +++ b/apps/web/src/features/operations/orders/OrderDetail.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +const OrderDetail = () => { + return ( +
OrderDetail
+ ) +} + +export default OrderDetail \ No newline at end of file diff --git a/apps/web/src/features/operations/orders/OrderList.tsx b/apps/web/src/features/operations/orders/OrderList.tsx new file mode 100644 index 00000000..14463846 --- /dev/null +++ b/apps/web/src/features/operations/orders/OrderList.tsx @@ -0,0 +1,343 @@ +import { Badge } from "@/common/components/ui/badge"; +import { Button } from "@/common/components/ui/button"; +import { Card, CardContent } from "@/common/components/ui/card"; +import { Input } from "@/common/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/common/components/ui/select"; +import DashboardLayout from "@/features/layouts/DashboardLayout"; +import { + Search, + Calendar, + Filter, + ArrowRight, + Clock, + CheckCircle, + AlertTriangle, + XCircle, + FileText +} from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; +import type { RootState } from "@/store/store"; +import { useListOrders, useListBusinesses } from "@/dataconnect-generated/react"; +import { dataConnect } from "@/features/auth/firebase"; +import { format, isWithinInterval, parseISO, startOfDay, endOfDay } from "date-fns"; +import { OrderStatus } from "@/dataconnect-generated"; + +export default function OrderList() { + const navigate = useNavigate(); + const [searchTerm, setSearchTerm] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [clientFilter, setClientFilter] = useState("all"); + const [dateRange, setDateRange] = useState<{ start: string; end: string }>({ start: "", end: "" }); + + const { user } = useSelector((state: RootState) => state.auth); + const isAdmin = user?.userRole === 'admin' || user?.userRole === 'ADMIN'; + + const { data: orderData, isLoading: loadingOrders } = useListOrders(dataConnect); + const { data: businessData, isLoading: loadingBusinesses } = useListBusinesses(dataConnect); + + const isLoading = loadingOrders || loadingBusinesses; + const orders = orderData?.orders || []; + const businesses = businessData?.businesses || []; + + const filteredOrders = useMemo(() => { + return orders.filter(order => { + // Search by Order # (ID) or Event Name + const matchesSearch = + order.id.toLowerCase().includes(searchTerm.toLowerCase()) || + (order.eventName?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false); + + // Filter by Status + const matchesStatus = statusFilter === "all" || order.status === statusFilter; + + // Filter by Client + const matchesClient = clientFilter === "all" || order.businessId === clientFilter; + + // Filter by Date Range + let matchesDate = true; + if (order.date) { + const orderDate = new Date(order.date); + if (dateRange.start && dateRange.end) { + matchesDate = isWithinInterval(orderDate, { + start: startOfDay(new Date(dateRange.start)), + end: endOfDay(new Date(dateRange.end)) + }); + } else if (dateRange.start) { + matchesDate = orderDate >= startOfDay(new Date(dateRange.start)); + } else if (dateRange.end) { + matchesDate = orderDate <= endOfDay(new Date(dateRange.end)); + } + } + + return matchesSearch && matchesStatus && matchesClient && matchesDate; + }); + }, [orders, searchTerm, statusFilter, clientFilter, dateRange]); + + const getStatusBadge = (status: OrderStatus) => { + switch (status) { + case OrderStatus.FULLY_STAFFED: + case OrderStatus.FILLED: + return Fully Staffed; + case OrderStatus.PARTIAL_STAFFED: + return Partial Staffed; + case OrderStatus.PENDING: + case OrderStatus.POSTED: + return {status}; + case OrderStatus.CANCELLED: + return Cancelled; + case OrderStatus.COMPLETED: + return Completed; + case OrderStatus.DRAFT: + return Draft; + default: + return {status}; + } + }; + + const calculateFillRate = (order: any) => { + const requested = order.requested || 0; + const assigned = Array.isArray(order.assignedStaff) ? order.assignedStaff.length : 0; + + if (requested === 0) return 0; + return Math.round((assigned / requested) * 100); + }; + + if (!isAdmin) { + return ( + +
+
+ +
+

Restricted Access

+

Only administrators are authorized to view the master order list.

+ +
+
+ ); + } + + return ( + +
+ {/* KPI Summary Cards */} +
+ + +
+
+

Total Orders

+

{orders.length}

+
+
+ +
+
+
+
+ + +
+
+

Pending/Posted

+

+ {orders.filter(o => o.status === OrderStatus.PENDING || o.status === OrderStatus.POSTED).length} +

+
+
+ +
+
+
+
+ + +
+
+

Partial

+

+ {orders.filter(o => o.status === OrderStatus.PARTIAL_STAFFED).length} +

+
+
+ +
+
+
+
+ + +
+
+

Filled

+

+ {orders.filter(o => o.status === OrderStatus.FULLY_STAFFED || o.status === OrderStatus.FILLED).length} +

+
+
+ +
+
+
+
+
+ + {/* Filters Section */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+
+ + + + +
+ setDateRange(prev => ({ ...prev, start: e.target.value }))} + /> +
to
+ setDateRange(prev => ({ ...prev, end: e.target.value }))} + /> +
+
+
+ + {/* Master Table */} +
+
+ + + + + + + + + + + + + + {isLoading ? ( + + + + ) : filteredOrders.length > 0 ? ( + filteredOrders.map((order) => { + const fillRate = calculateFillRate(order); + return ( + navigate(`/orders/${order.id}`)} + > + + + + + + + + + ); + }) + ) : ( + + + + )} + +
Order #Client NameEvent DateStatusPositionsFill Rate
+
+
+

Fetching orders...

+
+
+
+ #{order.id.split('-')[0].toUpperCase()} +
+
{order.eventName || 'Unnamed Event'}
+
+
{order.business.businessName}
+
+
+ + {order.date ? format(new Date(order.date), 'MMM d, yyyy') : 'TBD'} +
+
+ {getStatusBadge(order.status)} + +
+ {order.requested || 0} +
+
+
+
+ {fillRate}% +
+
+
0 ? 'bg-orange-500' : 'bg-slate-300' + }`} + style={{ width: `${fillRate}%` }} + /> +
+
+
+ +
+ No orders found matching your criteria. +
+
+
+
+
+ ); +} diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts new file mode 100644 index 00000000..1e6ed9b8 --- /dev/null +++ b/apps/web/src/lib/index.ts @@ -0,0 +1,17 @@ +export function createPageUrl(pageName: string) { + // Basic implementation based on MVP usage: navigate(createPageUrl('Events')) + // Assuming mapping based on pageName + if (pageName === 'Events') return '/orders'; + if (pageName === 'ClientOrders') return '/orders'; // Assuming same route for now + if (pageName === 'Invoices') return '/invoices'; // Assuming route exists + if (pageName.startsWith('EventDetail?id=')) { + const id = pageName.split('=')[1]; + return `/orders/${id}`; + } + if (pageName.startsWith('EditEvent?id=')) { + const id = pageName.split('=')[1]; + return `/orders/${id}/edit`; + } + + return '/' + pageName.toLowerCase().replace(/ /g, '-'); +} diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index c71d6d3c..41b03fc6 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -16,6 +16,8 @@ import ClientList from './features/business/clients/ClientList'; import EditClient from './features/business/clients/EditClient'; import AddClient from './features/business/clients/AddClient'; import ServiceRates from './features/business/rates/ServiceRates'; +import OrderList from './features/operations/orders/OrderList'; +import OrderDetail from './features/operations/orders/OrderDetail'; /** @@ -91,6 +93,10 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + {/* Operations Routes */} + } /> + } /> + } />