diff --git a/README.md b/README.md index 6ccf07e..f431046 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,113 @@ -# Plano Realty CRM - Field Service & Sales Platform +# Plano Realty CRM - Geospatial Field Service Platform -**Plano Realty CRM** is a next-generation Geospatial CRM designed for roofing professionals in Plano, Texas. It specifically targets the coordination between field agents and customers, leveraging AI and real-time data to streamline the sales and inspection process. +> **The Next Generation of Roofing Sales & Territory Management** -## πŸš€ Product Overview - -- **Purpose**: To replace outdated spreadsheet tracking with an interactive, map-based interface for managing roofing leads and inspections. -- **Core Functionality**: - - **Interactive Maps**: Visual territory management with property polygons. - - **AI Concierge**: A chatbot (powered by Groq/Qwen) that schedules meetings and answers context-aware questions. - - **Dashboard**: Real-time metrics, weather integration (OpenWeatherMap), and schedule management. - - **Role-Based Access**: Distinct interfaces for **Field Agents**, **Admins**, and **Customers**. - -## πŸ›  Implementation Details - -The system is built as a **Single Page Application (SPA)** using: - -- **Frontend Framework**: React 18 + Vite -- **Styling**: Tailwind CSS (Dark Mode Native) + Glassmorphism UI -- **State Management**: React Context API (`AuthContext`, `MockStore`) -- **AI Integration**: Groq SDK (running `qwen-2.5-32b`) for natural language processing. -- **Maps**: Custom SVG/Canvas implementations for property visualization. -- **Animations**: GSAP (GreenSock) for high-performance interactions. - -## πŸ“‚ Project Structure - -```text -/src -β”œβ”€β”€ assets/ # Static images and icons -β”œβ”€β”€ components/ # Reusable UI components -β”‚ β”œβ”€β”€ Chatbot.jsx # AI Floating Widget -β”‚ β”œβ”€β”€ Layout.jsx # App Sidebar & Shell -β”‚ β”œβ”€β”€ SpotlightCard.jsx # Premium Card UI with Glow -β”‚ └── ... -β”œβ”€β”€ config/ # Environment variables -β”‚ └── env.js # Centralized config loader -β”œβ”€β”€ context/ # Global State -β”‚ β”œβ”€β”€ AuthContext.jsx # User Authentication Logic -β”‚ └── ThemeContext.jsx # Dark Mode Management -β”œβ”€β”€ data/ # Data Layer -β”‚ └── mockStore.jsx # Mock Data Generator (Users, Meetings, Properties) -β”œβ”€β”€ pages/ # Route Views -β”‚ β”œβ”€β”€ Dashboard.jsx # Agent KPI View -β”‚ β”œβ”€β”€ Landing.jsx # Public Sales Page -β”‚ β”œβ”€β”€ Login.jsx # Authentication Pge -β”‚ └── Maps.jsx # Territory Map View -└── utils/ # Helpers - └── logger.js # Structured Logging -``` - -## ⚑ Running the Project - -1. **Prerequisites**: Node.js (v18+) and `pnpm`. -2. **Installation**: - ```bash - pnpm install - ``` -3. **Environment Setup**: - - Create a `.env` file in the root. - - Add keys: - ```env - VITE_GROQ_API_KEY=your_key_here - VITE_OPENWEATHER_API_KEY=your_key_here - ``` -4. **Start Dev Server**: - ```bash - pnpm run dev - ``` - Access the app at `http://localhost:5173`. - -## πŸ“ File Responsibilities - -### `src/data/mockStore.jsx` -The heart of the application's data layer. Since there is no backend, this file generates realistic seed data. - -- **`generateProperties()`**: Creates ~60 property records with geospatial coordinates, ownership details, and styles (Commercial vs Residential). -- **`generateMockMeetings()`**: Simulates agent schedules, ensuring a mix of past (completed) and future (scheduled) appointments. - -### `src/components/Chatbot.jsx` -Handles all AI interactions. -- **Input**: User text + Current User Context + Meeting Schedule. -- **Processing**: Sends prompt to Groq API with a custom system prompt ensuring "Americana" branding. -- **Output**: Renders Markdown responses and handles structured `CALLBACK_REQUEST` JSON for scheduling. - -### `src/context/AuthContext.jsx` -Manages user sessions. -- **`login(identifier, password, type)`**: Validates credentials against `MOCK_USERS`. -- **`user` object**: Contains `role`, `name`, `id`, and permissions. - -## πŸ—ΊοΈ Map Functionality & Geospatial Logic - -The map interface (`src/pages/Maps.jsx`) is the core operational view, overlaying business data on real-world geography. - -### 1. Visualization & Overlays -- **Rendering Engine**: Powered by `react-leaflet`. -- **Polygons**: Property boundaries are drawn using the `Polygon` component, taking an array of `[lat, lng]` coordinates. -- **Status Styling**: - - **Hot Lead**: Red Fill (`#ef4444`) - - **Customer**: Emerald Fill (`#10b981`) - - **Renovated**: Blue Fill (`#3b82f6`) - - **Neutral**: Zinc Fill (Default) - -### 2. Interaction & Data Retrieval -- **Click-to-Reveal**: Clicking a polygon triggers `onSelect(property)`, opening the **Property Drawer**. - - **Returns**: Full `propertyData` (Address, Build Year, Value) and `ownerData` (Name, Contact, Intent). -- **Reverse Geocoding (New Property)**: - - **Trigger**: Clicking an empty map area (Field Agents only). - - **API Call**: `https://nominatim.openstreetmap.org/reverse` - - **Parameters**: `lat`, `lon`, `format='json'`. - - **Return**: Returns a JSON object where `display_name` is extracted as the property address. - -## πŸ“š Function Documentation - -### `useMockStore: addMeeting(meeting)` -Adds a new meeting to the global state. -- **Inputs**: - - `meeting` (Object): `{ agentId, customerName, time, date, propertyId, status }` -- **Returns**: `void` -- **Side Effect**: Updates the `meetings` array and triggers a re-render of the Dashboard and Chatbot context. - -### `config.isDemoMode(key)` -Checks if the system should run in simulated demo mode. -- **Inputs**: `key` (String) - The API key from environment variables. -- **Returns**: `Boolean` - `true` if key is missing or is the default placeholder. - -## πŸ”Œ APIs & Integrations - -| Service | Purpose | Key Variable | -| :--- | :--- | :--- | -| **Groq Cloud** | LLM Inference (Chatbot) | `VITE_GROQ_API_KEY` | -| **OpenWeather** | Dashboard Weather Widget | `VITE_OPENWEATHER_API_KEY` | +**Plano Realty CRM** is an advanced, data-driven platform designed to revolutionize how roofing professionals in Plano, Texas manage their territories, leads, and customer interactions. By combining geospatial intelligence, AI-driven insights, and intuitive workflows, we bridge the gap between field agents and office operations. --- -**Created by Satyam-Rastogi for Plano Realty CRM** + +## πŸ›οΈ PART 1: PRODUCT OVERVIEW + +### 🌟 Value Proposition +In a competitive roofing market, speed and data are everything. Traditional spreadsheets and paper maps leave money on the table. **Plano Realty CRM** solves this by providing: +- **Precision Targeting**: Identify "Golden Leads" (older homes with high value) instantly on a map. +- **Operational Efficiency**: Automate scheduling and follow-ups with our AI Concierge. +- **Real-Time Intelligence**: Know the storm risk and roof condition of every property before you knock. + +### πŸ€– The "Plano AI Assistant" (Chatbot) +Our AI is not just a support bot; it is a context-aware worker that understands your business data. + +#### For Field Agents (Workers) +- **"Smart Querying"**: Ask *β€œShow me the properties built before 2000”* or *β€œWhich homeowner wants to sell?”*. +- **Lead Qualification**: The AI knows which leads are "Hot" and prompts you to prioritize them. +- **Risk Analysis**: Ask *β€œWhat is the current storm risk?”* to get a breakdown of wind speeds vs. roof vulnerability. + +#### For Customers (Homeowners) +- **Instant Diagnostics**: (Future) Schedule inspections without waiting on hold. +- **Project Updates**: Check the status of their roof repair. + +#### For Guests (Website Visitors) +- **Lead Capture**: The AI engages visitors, answers generic questions about roofing services, and encourages them to sign up or request a free estimate. + +### πŸ“Š The Data Dashboard +A 360-degree view of your territory's health. + +1. **Golden Leads Scatter Plot**: Visualizes high-value opportunities (High Value + Older Built Year). +2. **Risk Index Gauge**: A real-time meter combining live wind speed data with the percentage of "Needs Repair" roofs. +3. **Revenue Potential**: Bar charts showing which neighborhoods offer the highest ROI based on renovation needs. +4. **Roof Health**: A visual breakdown of the entire territory's roof conditions (Good, Fair, Needs Repair). +5. **Owner Intent Funnel**: Tracks the pipeline from "Willing to Sell" -> "Hot Leads". + +--- + +## πŸ‘₯ USER ROLES & DEMO ACCOUNTS + +To explore the platform, use these pre-configured accounts. + +### 1. Field Agent (Worker) +*The primary user. Can view maps, access detailed property data, and manage leads.* +- **Username/ID**: `EMP001` +- **Password**: `password123` +- **Role Capabilities**: + - Access Dashboard & Widget Data. + - View/Edit Lead Status (Hot, Cold, Customer). + - Use Chatbot with full data context. + +### 2. Administrator +*The manager. Sees all agents and global stats.* +- **Username/ID**: `ADMIN01` +- **Password**: `adminpass` +- **Role Capabilities**: + - All Agent capabilities + User Management (Mock). + +### 3. Customer (Homeowner) +*The client. Limited view of their own property.* +- **Username**: `john_doe` +- **Password**: `customer123` +- **Role Capabilities**: + - View personal property details. + - Request Service. + +--- + +## πŸ’» PART 2: DEVELOPER GUIDE + +### πŸ›  Tech Stack & Architecture + +- **Frontend**: React 18 (Vite), Tailwind CSS, Glassmorphism UI. +- **State**: `MockStore` (Simulates Backend/DB), `AuthContext` (Session Management). +- **AI Engine**: Groq SDK (`qwen-2.5-32b`) injected with dynamic system prompts. +- **Visuals**: `react-leaflet` (Maps), `recharts` (Analytics), `lucide-react` (Icons). + +### 🧠 Deep Dive: How It Works + +#### 1. The "Mock Store" (Data Layer) +Located in `src/data/mockStore.jsx`. It generates ~60 realistic property records using weighted randomization algorithms to create organic-looking data distributions (e.g., 10% bad roofs, random build years). +- **Why?**: To provide a rich, testable dataset without needing a live backend database. + +#### 2. AI Context Injection +Located in `src/components/Chatbot.jsx`. +- When a user sends a message, we don't just send their text. We inject a **System Prompt** containing live dashboard metrics. +- **Example Injection**: + ```text + SYSTEM: You are a roofing assistant. + CONTEXT: + - 45 Total Properties + - 12 Golden Leads (Top: 123 Maple Dr) + - 8 Hot Leads needing canvassing + ``` +- This allows the AI to answer specific questions accurately. + +#### 3. Leaflet Customization +Located in `src/pages/Maps.jsx`. +- We use custom SVGs overlays on the map. +- **Reverse Geocoding**: Clicking a blank spot calls the OpenStreetMap API to fetch the address, allowing agents to "create" new property records on the fly. + +### πŸš€ Setup & Contribution + +1. **Clone**: `git clone ` +2. **Install**: `pnpm install` +3. **Env**: Create `.env` with `VITE_GROQ_API_KEY` and `VITE_OPENWEATHER_API_KEY`. +4. **Run**: `pnpm run dev` + +**Maintained by Satyam Rastogi** diff --git a/package-lock.json b/package-lock.json index 46d2ffb..aee7112 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-leaflet": "^5.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "recharts": "^3.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.1" @@ -1075,6 +1076,42 @@ "react-dom": "^19.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1432,6 +1469,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1477,6 +1526,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1577,6 +1689,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2153,6 +2271,127 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2170,6 +2409,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2298,6 +2543,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -2566,6 +2821,12 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3036,6 +3297,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3069,6 +3340,15 @@ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -4536,6 +4816,13 @@ "react": "^19.2.3" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, "node_modules/react-leaflet": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", @@ -4577,6 +4864,30 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4658,6 +4969,52 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -4691,6 +5048,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -5033,6 +5396,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5239,6 +5608,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5273,6 +5651,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index d9d0704..1d58aad 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-leaflet": "^5.0.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.13.0", + "recharts": "^3.7.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss": "^3.4.1" @@ -39,4 +40,4 @@ "globals": "^16.5.0", "vite": "^7.2.4" } -} \ No newline at end of file +} diff --git a/src/components/Chatbot.jsx b/src/components/Chatbot.jsx index 2e59585..4607f89 100644 --- a/src/components/Chatbot.jsx +++ b/src/components/Chatbot.jsx @@ -39,7 +39,7 @@ GUIDELINES: const Chatbot = () => { const { user } = useAuth(); - const { addMeeting, meetings } = useMockStore(); + const { addMeeting, meetings, properties } = useMockStore(); const [isOpen, setIsOpen] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [input, setInput] = useState(''); @@ -127,6 +127,44 @@ ${scheduleSummary}`; systemContext += `\n\nCURRENT USER CONTEXT: Guest (Not Logged In)`; } + // --- INJECT DASHBOARD DATA --- + // Calculate real-time metrics to give the AI context about the territory + if (properties && properties.length > 0) { + const totalProps = properties.length; + + // 1. Golden Leads + const goldenLeads = properties.filter(p => + p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000 + ); + const goldenLeadAddresses = goldenLeads.slice(0, 3).map(p => p.propertyData.propertyAddress).join(', '); + + // 2. Hot Leads + const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead'); + + // 3. Roof Health + const roofCounts = properties.reduce((acc, p) => { + const c = p.propertyData.roofCondition || 'Unknown'; + acc[c] = (acc[c] || 0) + 1; + return acc; + }, {}); + + // 4. Intent + const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length; + const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length; + + // 5. Risk Index (simplified calculation for context) + const vulnerableCount = (roofCounts['Fair'] || 0) + (roofCounts['Needs Repair'] || 0); + const riskPercent = Math.round((vulnerableCount / totalProps) * 100); + + systemContext += `\n\nLIVE DASHBOARD INTELLIGENCE (Use this to answer questions about leads and territory status): + - Total Properties in Territory: ${totalProps} + - GOLDEN LEADS (High Value + Older Homes): ${goldenLeads.length} found. (Top examples: ${goldenLeadAddresses}...) + - HOT LEADS (Priority for Canvassing): ${hotLeads.length} properties. + - OWNER INTENT: ${willingToSell} owners willing to sell, ${willingToRent} willing to rent. + - ROOF HEALTH BREAKDOWN: Excellent: ${roofCounts['Excellent'] || 0}, Good: ${roofCounts['Good'] || 0}, Fair: ${roofCounts['Fair'] || 0}, Needs Repair: ${roofCounts['Needs Repair'] || 0}. + - RISK ANALYSIS: ${riskPercent}% of territory has vulnerable roofs.`; + } + const chatCompletion = await groq.chat.completions.create({ messages: [ { role: 'system', content: systemContext }, diff --git a/src/components/InfoTooltip.jsx b/src/components/InfoTooltip.jsx new file mode 100644 index 0000000..056b34e --- /dev/null +++ b/src/components/InfoTooltip.jsx @@ -0,0 +1,69 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Info } from 'lucide-react'; + +export const InfoTooltip = ({ text }) => { + const [isVisible, setIsVisible] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + const triggerRef = useRef(null); + + const updatePosition = () => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setCoords({ + top: rect.top - 10, // Default to slightly above + left: rect.left + rect.width / 2 + }); + } + }; + + const handleMouseEnter = () => { + updatePosition(); + setIsVisible(true); + }; + + const handleMouseLeave = () => { + setIsVisible(false); + }; + + useEffect(() => { + if (isVisible) { + window.addEventListener('scroll', updatePosition); + window.addEventListener('resize', updatePosition); + } + return () => { + window.removeEventListener('scroll', updatePosition); + window.removeEventListener('resize', updatePosition); + }; + }, [isVisible]); + + return ( + <> +
+ +
+ + {isVisible && createPortal( +
+ {text} + {/* Tiny triangle pointer */} +
+
, + document.body + )} + + ); +}; diff --git a/src/components/dashboard/GoldenLeadsScatter.jsx b/src/components/dashboard/GoldenLeadsScatter.jsx new file mode 100644 index 0000000..1372be5 --- /dev/null +++ b/src/components/dashboard/GoldenLeadsScatter.jsx @@ -0,0 +1,82 @@ +import React, { useMemo } from 'react'; +import { ScatterChart, Scatter, XAxis, YAxis, Tooltip, ResponsiveContainer, ZAxis } from 'recharts'; +import { SpotlightCard } from '../SpotlightCard'; +import { InfoTooltip } from '../InfoTooltip'; + +const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.address}

+

Built: {data.year}

+

Value: ${data.value.toLocaleString()}

+
+ ); + } + return null; +}; + +export const GoldenLeadsScatter = ({ properties }) => { + const data = useMemo(() => { + return properties.map(p => ({ + x: p.propertyData.yearBuilt, + y: p.propertyData.currentEstimatedMarketValue, + z: 100, // bubble size + address: p.propertyData.propertyAddress.split(',')[0], + year: p.propertyData.yearBuilt, + value: p.propertyData.currentEstimatedMarketValue, + isGolden: p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000 + })); + }, [properties]); + + return ( + +
+
+
+

+ Golden Leads + +

+

High Value + Older Homes

+
+
+ Opportunity Zone +
+
+ +
+ + + + `${value / 1000}k`} + axisLine={false} + tickLine={false} + /> + } cursor={{ strokeDasharray: '3 3' }} /> + + + +
+
+
+ ); +}; diff --git a/src/components/dashboard/OwnerIntentFunnel.jsx b/src/components/dashboard/OwnerIntentFunnel.jsx new file mode 100644 index 0000000..f75ff93 --- /dev/null +++ b/src/components/dashboard/OwnerIntentFunnel.jsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import { SpotlightCard } from '../SpotlightCard'; +import { InfoTooltip } from '../InfoTooltip'; + +const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].payload.name}

+

+ {payload[0].value} Owners +

+
+ ); + } + return null; +}; + +export const OwnerIntentFunnel = ({ properties }) => { + const data = useMemo(() => { + const total = properties.length; + const willingToSell = properties.filter(p => p.ownerData.willingToSellProperty).length; + const willingToRent = properties.filter(p => p.ownerData.willingToRentProperty).length; + const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead').length; + + return [ + { name: 'Total Owners', value: total, color: '#3f3f46' }, // zinc-700 + { name: 'Willing to Sell', value: willingToSell, color: '#8b5cf6' }, // violet-500 + { name: 'Willing to Rent', value: willingToRent, color: '#3b82f6' }, // blue-500 + { name: 'Hot Leads', value: hotLeads, color: '#f97316' }, // orange-500 + ]; + }, [properties]); + + return ( + +
+
+

+ Owner Intent + +

+

Conversion pipeline

+
+ +
+ + + + + } cursor={{ fill: 'transparent' }} /> + + {data.map((entry, index) => ( + + ))} + + + +
+
+
+ ); +}; diff --git a/src/components/dashboard/RevenueByNeighborhood.jsx b/src/components/dashboard/RevenueByNeighborhood.jsx new file mode 100644 index 0000000..fcc83bf --- /dev/null +++ b/src/components/dashboard/RevenueByNeighborhood.jsx @@ -0,0 +1,76 @@ +import React, { useMemo } from 'react'; +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'; +import { SpotlightCard } from '../SpotlightCard'; +import { InfoTooltip } from '../InfoTooltip'; + +const CustomTooltip = ({ active, payload, label }) => { + if (active && payload && payload.length) { + return ( +
+

Rating: {label}/10

+

+ Potential Value: ${payload[0].value.toLocaleString()} +

+
+ ); + } + return null; +}; + +export const RevenueByNeighborhood = ({ properties }) => { + const data = useMemo(() => { + // Group by neighborhood rating for now (simulating "Neighborhoods") + const groups = properties.reduce((acc, p) => { + const rating = p.propertyData.neighborhoodRating || 0; + // Summing Potential Revenue (using repair cost as proxy or just market value significance) + // Let's use Renovation Cost to show "Repair Opportunity" + const value = p.propertyData.renovationCost || 0; + acc[rating] = (acc[rating] || 0) + value; + return acc; + }, {}); + + return Object.keys(groups).map(key => ({ + rating: key, + value: groups[key] + })).sort((a, b) => b.rating - a.rating); // Higher rated neighborhoods first + }, [properties]); + + return ( + +
+
+

+ Revenue Potential + +

+

By Neighborhood Rating

+
+ +
+ + + + + `$${value / 1000}k`} + /> + } cursor={{ fill: 'rgba(255,255,255,0.05)' }} /> + + + +
+
+
+ ); +}; diff --git a/src/components/dashboard/RoofConditionChart.jsx b/src/components/dashboard/RoofConditionChart.jsx new file mode 100644 index 0000000..b3db581 --- /dev/null +++ b/src/components/dashboard/RoofConditionChart.jsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import { SpotlightCard } from '../SpotlightCard'; +import { InfoTooltip } from '../InfoTooltip'; + +const COLORS = { + 'Excellent': '#10b981', // emerald-500 + 'Good': '#3b82f6', // blue-500 + 'Fair': '#eab308', // yellow-500 + 'Needs Repair': '#ef4444' // red-500 +}; + +const CustomTooltip = ({ active, payload }) => { + if (active && payload && payload.length) { + return ( +
+

{payload[0].name}

+

+ {payload[0].value} Properties ({((payload[0].payload.percent || 0) * 100).toFixed(0)}%) +

+
+ ); + } + return null; +}; + +export const RoofConditionChart = ({ properties }) => { + const data = useMemo(() => { + const counts = properties.reduce((acc, p) => { + const condition = p.propertyData.roofCondition || 'Unknown'; + acc[condition] = (acc[condition] || 0) + 1; + return acc; + }, {}); + + const total = properties.length; + + return Object.keys(counts).map(key => ({ + name: key, + value: counts[key], + percent: counts[key] / total + })); + }, [properties]); + + return ( + +
+
+
+

+ Roof Condition + +

+

Territory health snapshot

+
+ {/* Legend / Key - simpler than Recharts legend */} +
+
+
Good +
+
+
Bad +
+
+
+ +
+ + + + {data.map((entry, index) => ( + + ))} + + } /> + + + + {/* Center Text */} +
+
+ {properties.length} + Total +
+
+
+
+
+ ); +}; diff --git a/src/components/dashboard/WeatherRiskGauge.jsx b/src/components/dashboard/WeatherRiskGauge.jsx new file mode 100644 index 0000000..4b163f6 --- /dev/null +++ b/src/components/dashboard/WeatherRiskGauge.jsx @@ -0,0 +1,108 @@ +import React, { useMemo } from 'react'; +import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'; +import { SpotlightCard } from '../SpotlightCard'; +import { Wind, AlertTriangle, ShieldCheck } from 'lucide-react'; +import { InfoTooltip } from '../InfoTooltip'; + +export const WeatherRiskGauge = ({ properties, weather }) => { + const riskScore = useMemo(() => { + // 1. Weather Factor (0-50 points) + // Wind > 20mph = 50pts, 0mph = 0pts + let weatherScore = 0; + if (weather && weather.wind) { + weatherScore = Math.min(weather.wind.speed * 2.5, 50); + } + + // 2. Territory Vulnerability (0-50 points) + // % of roofs in "Needs Repair" or "Fair" + const vulnerableCount = properties.filter(p => + ['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition) + ).length; + const vulnerabilityRatio = vulnerableCount / (properties.length || 1); + const roofScore = vulnerabilityRatio * 50; + + return Math.min(Math.round(weatherScore + roofScore), 100); + }, [properties, weather]); + + const data = [ + { value: riskScore }, + { value: 100 - riskScore } + ]; + + const getColor = (score) => { + if (score < 30) return '#10b981'; // Green + if (score < 70) return '#eab308'; // Yellow + if (score >= 70) return '#ef4444'; // Red + return '#10b981'; + }; + + const color = getColor(riskScore); + + return ( + +
+ + {/* Background Glow */} +
+ +
+

+ {riskScore > 70 ? : } + Risk Index + +

+

Storm Impact Probability

+
+ +
+ {/* Speedometer Chart */} + + + + + {/* zinc-800 for empty part */} + + + + + {/* Score in Center */} +
+
+ {riskScore} +
+
+ /100 +
+
+
+ +
+
+ Wind Exp. + + {weather?.wind?.speed ? Math.round(weather.wind.speed) : 0} mph + +
+
+ Vulnerable + + {Math.round((properties.filter(p => ['Fair', 'Needs Repair'].includes(p.propertyData.roofCondition)).length / properties.length) * 100)}% + +
+
+ +
+ + ); +}; diff --git a/src/data/mockStore.jsx b/src/data/mockStore.jsx index f9a861a..b361f05 100644 --- a/src/data/mockStore.jsx +++ b/src/data/mockStore.jsx @@ -165,11 +165,27 @@ function generateEmail(name, company = null) { return `${first}.${last}@${domain}`; } +// Weighted Random Helper +function getWeightedRandom(items, weights) { + const totalWeight = weights.reduce((a, b) => a + b, 0); + const random = Math.random() * totalWeight; + let weightSum = 0; + + for (let i = 0; i < items.length; i++) { + weightSum += weights[i]; + if (random <= weightSum) { + return items[i]; + } + } + return items[items.length - 1]; // Fallback +} + function generateProperties() { return RAW_LOCATIONS.map((loc, index) => { const isCommercial = loc.type === "Commercial" || loc.type === "Apartments"; - const styleIndex = (index * 7) % PROPERTY_STYLES.length; + // Shuffle styles slightly differently than index-based to avoid repeating patterns + const styleIndex = Math.floor(Math.random() * PROPERTY_STYLES.length); const style = isCommercial ? null : PROPERTY_STYLES[styleIndex]; let ownerName, ownerEmail, occupation, income, employerName; @@ -191,20 +207,44 @@ function generateProperties() { income = ["$80k - $120k", "$120k - $180k", "$200k+", "$60k - $90k"][index % 4]; } - const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + (index % 15); - const sqft = isCommercial ? 12000 + (index * 500) : style.areaBase + (index * 132); - const value = isCommercial ? 2500000 + (index * 100000) : style.baseValue + (index * 12500); + // Randomize Year and Value to break lines + const yearOffset = Math.floor(Math.random() * 20) - 10; // +/- 10 years + const builtYear = isCommercial ? 1995 + (index % 10) : style.yearBase + yearOffset; - const isRented = index % 3 === 0; + const valueVariance = (Math.random() * 0.4) - 0.2; // +/- 20% + const baseSqft = isCommercial ? 12000 + (index * 500) : style.areaBase; + const sqft = Math.floor(baseSqft * (1 + (valueVariance / 2))); // Size correlates with value but not perfectly + + const baseVal = isCommercial ? 2500000 + (index * 100000) : style.baseValue; + const value = Math.floor(baseVal * (1 + valueVariance)); + + const isRented = Math.random() > 0.7; // 30% rented const tenantName = isRented ? (isCommercial ? "Multiple Commercial Tenants" : REAL_NAMES[(index + 5) % REAL_NAMES.length]) : null; const tenantOcc = isRented ? (isCommercial ? "Retail/Office Mix" : OCCUPATIONS[(index + 3) % OCCUPATIONS.length]) : null; const rentAmount = isRented ? Math.floor(value * 0.008 / 100) * 100 : null; + // Weighted Roof Condition + // 40% Good, 30% Fair, 20% Excellent, 10% Needs Repair + const roofCondition = getWeightedRandom( + ["Good", "Fair", "Excellent", "Needs Repair"], + [0.4, 0.3, 0.2, 0.1] + ); + + // Canvassing Status Randomization + // 60% Neutral, 20% Hot Lead, 10% Customer, 10% Renovated + const status = isCommercial ? "Neutral" : getWeightedRandom( + ["Neutral", "Hot Lead", "Customer", "Renovated"], + [0.6, 0.2, 0.1, 0.1] + ); + + // Neighborhood Rating (Weighted towards high 7-9) + const neighRating = getWeightedRandom([7, 8, 9, 10, 6, 5], [0.3, 0.3, 0.2, 0.1, 0.05, 0.05]); + return { id: index + 1, center: [loc.lat, loc.lng], polygon: generatePolygon(loc.lat, loc.lng), - canvassingStatus: isCommercial ? "Neutral" : style.canvassingStatus, + canvassingStatus: status, photos: isCommercial ? ["https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=800", "https://images.unsplash.com/photo-1542665952-14513db15293?w=800"] : style.photos, propertyData: { propertyId: `P-${2600 + index}`, @@ -217,9 +257,9 @@ function generateProperties() { numberOfParkingSpaces: isCommercial ? 25 : 2, currentEstimatedMarketValue: value, propertyTaxAssessmentValue: Math.floor(value * 0.85), - roofCondition: ["Good", "Fair", "Excellent", "Needs Repair"][index % 4], + roofCondition: roofCondition, renovationDescription: isCommercial ? "Commercial Maintenance" : style.desc, - renovationCost: Math.floor(value * 0.05), + renovationCost: Math.floor(value * (Math.random() * 0.05 + 0.01)), // 1-6% of value lastMajorRenovationDate: "2020-05-15", lastRoofRepairReplacementDate: "2018-02-10", recentMajorRepairs: "Routine Maintenance", @@ -230,12 +270,12 @@ function generateProperties() { currentMonthlyRentAmount: rentAmount, leaseStartDate: isRented ? "2023-01-01" : null, leaseEndDate: isRented ? "2025-01-01" : null, - ownerWillingToRent: index % 4 === 0, + ownerWillingToRent: !isRented && Math.random() > 0.6, expectedMonthlyRentAmount: Math.floor(value * 0.008 / 100) * 100, rentalAvailabilityDate: null, rentalTermsPreference: null, schoolDistrict: "Plano ISD", - neighborhoodRating: 8 + (index % 3), + neighborhoodRating: neighRating, crimeRateIndex: "Low", publicTransportationAccess: "Bus Line 204", propertyConditionRating: isCommercial ? 4 : style.condition, @@ -260,10 +300,10 @@ function generateProperties() { creditScoreRange: "720+", ownershipType: isCommercial ? "Corporate" : "Individual", ownershipPercentage: "100%", - willingToRentProperty: false, + willingToRentProperty: Math.random() > 0.8, desiredMonthlyRentalPrice: null, rentalConditions: null, - willingToSellProperty: index % 5 === 0, + willingToSellProperty: Math.random() > 0.85, desiredSellingPrice: Math.floor(value * 1.1), minimumAcceptableSellingPrice: Math.floor(value * 1.05) } diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 05a1d3f..072cbdd 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -4,15 +4,83 @@ import { useAuth } from '../context/AuthContext'; import { Link } from 'react-router-dom'; import { DollarSign, Flame, Calendar, CloudRain, Wind, Droplets, Thermometer, - MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2 + MapPin, Clock, ArrowUpRight, Sun, Cloud, AlertTriangle, Loader2, ShieldCheck } from 'lucide-react'; import { SpotlightCard } from '../components/SpotlightCard'; import { config } from '../config/env'; +// New Widget Imports +import { RoofConditionChart } from '../components/dashboard/RoofConditionChart'; +import { OwnerIntentFunnel } from '../components/dashboard/OwnerIntentFunnel'; +import { RevenueByNeighborhood } from '../components/dashboard/RevenueByNeighborhood'; +import { GoldenLeadsScatter } from '../components/dashboard/GoldenLeadsScatter'; +import { WeatherRiskGauge } from '../components/dashboard/WeatherRiskGauge'; + const Dashboard = () => { const { properties, meetings } = useMockStore(); const { user } = useAuth(); + // --- Weather State (Lifted) --- + const [weather, setWeather] = useState(null); + const [forecast, setForecast] = useState([]); + const [weatherLoading, setWeatherLoading] = useState(true); + const [weatherError, setWeatherError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + const fetchWeather = async () => { + const API_KEY = config.weatherApiKey; + const CITY = "Plano,US"; + try { + setWeatherLoading(true); + + if (config.isDemoMode(API_KEY)) { + await new Promise(resolve => setTimeout(resolve, 800)); + const mockCurrent = { + name: "Plano", + sys: { country: "US" }, + main: { temp: 75, temp_max: 82, temp_min: 68, feels_like: 77, humidity: 45 }, + weather: [{ id: 800, description: "Clear Sky (Demo)" }], + wind: { speed: 8 } + }; + const mockForecast = Array.from({ length: 5 }).map((_, i) => ({ + dt: Math.floor(Date.now() / 1000) + (i * 10800), + main: { temp: 75 + (i % 2 === 0 ? 2 : -2) }, + weather: [{ id: 800 + (i * 10) }] + })); + setWeather(mockCurrent); + setForecast(mockForecast); + setLastUpdated(new Date()); + setWeatherLoading(false); + setWeatherError(null); + return; + } + + const currentRes = await fetch(`https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}`); + if (!currentRes.ok) throw new Error(currentRes.statusText); + const currentData = await currentRes.json(); + + const forecastRes = await fetch(`https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}`); + if (!forecastRes.ok) throw new Error(forecastRes.statusText); + const forecastData = await forecastRes.json(); + + setWeather(currentData); + setForecast(forecastData.list?.slice(0, 5) || []); + setLastUpdated(new Date()); + setWeatherLoading(false); + setWeatherError(null); + } catch (err) { + console.error("Weather fetch failed", err); + setWeatherError(err.message); + setWeatherLoading(false); + } + }; + + useEffect(() => { + fetchWeather(); + const interval = setInterval(fetchWeather, 600000); + return () => clearInterval(interval); + }, []); + // Clock for Plano, TX const [currentTime, setCurrentTime] = useState(new Date()); @@ -24,11 +92,7 @@ const Dashboard = () => { // Derived Metrics & Split Schedule const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead'); - - // Filter meetings for current user (or fallback to e1 for dev) const allMyMeetings = meetings.filter(m => m.agentId === user?.id || m.agentId === 'e1'); - - // Split into Upcoming vs Past const today = new Date(); today.setHours(0, 0, 0, 0); @@ -103,7 +167,50 @@ const Dashboard = () => { />
- {/* Main Content Grid */} + {/* --- ANALYTICS ROW 1: Territory Health & Risk --- */} +
+ {/* Roof Condition - Donut */} +
+ +
+ + {/* Weather Risk - Gauge */} +
+ +
+ + {/* Live Weather Widget (Reused) */} +
+ +
+
+ + {/* --- ANALYTICS ROW 2: Opportunity & Intent --- */} +
+ {/* Revenue by Neighborhood */} +
+ +
+ + {/* Owner Intent Funnel */} +
+ +
+
+ + {/* --- ANALYTICS ROW 3: Golden Leads Scatter --- */} +
+ +
+ + {/* Main Content Grid (Leads & Schedule) */}
{/* Left Col: Hot Leads and Schedule */} @@ -145,53 +252,45 @@ const Dashboard = () => {
- - {/* Split Schedule System */} -
- - {/* Upcoming Meetings */} - -
-
-
-

Upcoming

-
-
- {upcomingMeetings.length === 0 ? ( -

No upcoming meetings.

- ) : ( - upcomingMeetings.map(meeting => ( - - )) - )} -
-
-
- - {/* Past / Completed Meetings */} - -
-
-
-

History

-
-
- {pastMeetings.length === 0 ? ( -

No past meetings.

- ) : ( - pastMeetings.slice(0, 5).map(meeting => ( - - )) - )} -
-
-
-
- {/* Right Col: Weather Widget */} + {/* Right Col: Schedule */}
- + +
+
+
+

Upcoming

+
+
+ {upcomingMeetings.length === 0 ? ( +

No upcoming meetings.

+ ) : ( + upcomingMeetings.map(meeting => ( + + )) + )} +
+
+
+ + +
+
+
+

History

+
+
+ {pastMeetings.length === 0 ? ( +

No past meetings.

+ ) : ( + pastMeetings.slice(0, 5).map(meeting => ( + + )) + )} +
+
+
@@ -249,95 +348,10 @@ const MetricCard = ({ title, value, trend, icon: Icon, trendColor, bgGlow }) => ); -const RealWeatherWidget = () => { - const [weather, setWeather] = useState(null); - const [forecast, setForecast] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); - - const API_KEY = config.weatherApiKey; - const CITY = "Plano,US"; - - const fetchWeather = async () => { - try { - setLoading(true); - - // --- DEMO MODE CHECK --- - if (config.isDemoMode(API_KEY)) { - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 800)); - - // MOCK DATA - const mockCurrent = { - name: "Plano", - sys: { country: "US" }, - main: { temp: 75, temp_max: 82, temp_min: 68, feels_like: 77, humidity: 45 }, - weather: [{ id: 800, description: "Clear Sky (Demo)" }], - wind: { speed: 8 } - }; - - // Mock Forecast (Next 15 hours) - const mockForecast = Array.from({ length: 5 }).map((_, i) => ({ - dt: Math.floor(Date.now() / 1000) + (i * 10800), // +3 hours each - main: { temp: 75 + (i % 2 === 0 ? 2 : -2) }, - weather: [{ id: 800 + (i * 10) }] // Varying sunny/cloudy codes - })); - - setWeather(mockCurrent); - setForecast(mockForecast); - setLastUpdated(new Date()); - setLoading(false); - setError(null); - return; // Exit early - } - - // --- REAL API FETCH --- - // Fetch Current Weather - const currentRes = await fetch( - `https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=imperial&appid=${API_KEY}` - ); - - if (!currentRes.ok) { - const errData = await currentRes.json().catch(() => ({ message: currentRes.statusText })); - throw new Error(errData.message || `API Error: ${currentRes.status}`); - } - - const currentData = await currentRes.json(); - - // Fetch 5 Day / 3 Hour Forecast - const forecastRes = await fetch( - `https://api.openweathermap.org/data/2.5/forecast?q=${CITY}&units=imperial&appid=${API_KEY}` - ); - - if (!forecastRes.ok) { - const errData = await forecastRes.json().catch(() => ({ message: forecastRes.statusText })); - throw new Error(errData.message || `Forecast Error: ${forecastRes.status}`); - } - - const forecastData = await forecastRes.json(); - - setWeather(currentData); - setForecast(forecastData.list?.slice(0, 5) || []); - setLastUpdated(new Date()); - setLoading(false); - setError(null); - } catch (err) { - console.error("Weather fetch failed", err); - setError(err.message); - setLoading(false); - } - }; - - useEffect(() => { - fetchWeather(); - // Refresh every 10 minutes - const interval = setInterval(fetchWeather, 600000); - return () => clearInterval(interval); - }, []); +const RealWeatherWidget = ({ weather, forecast, loading, error, lastUpdated, onRetry }) => { + // --- Icons Logic --- const getWeatherIcon = (code) => { - // OWM codes: https://openweathermap.org/weather-conditions if (code === 800) return ; if (code > 800) return ; if (code >= 500 && code < 600) return ; @@ -352,11 +366,11 @@ const RealWeatherWidget = () => { return ; }; - if (loading && !weather) return
Syncing OWM...
; + if (loading && !weather) return
Syncing OWM...
; if (error) return ( -
+

Weather Unavailable

@@ -367,24 +381,23 @@ const RealWeatherWidget = () => { Please configure your VITE_OPENWEATHER_API_KEY in the .env file.

)} -
); - if (!weather || !weather.main) return
Weather Unavailable
; + if (!weather || !weather.main) return
Weather Unavailable
; return ( - // Added text-zinc-900 to ensure text is visible in light mode - + {/* Dynamic Weather Background Gradient - subtle */}
-
-
+
+
@@ -409,15 +422,10 @@ const RealWeatherWidget = () => {
{getWeatherIcon(weather.weather[0].id)}
-

{weather.weather[0].description}

-
- H: {Math.round(weather.main.temp_max)}Β°F L: {Math.round(weather.main.temp_min)}Β°F - Feels: {Math.round(weather.main.feels_like)}Β°F -
-
+
@@ -435,7 +443,6 @@ const RealWeatherWidget = () => {
-

Forecast (3-Hour Steps)

{forecast.map((item, index) => { const date = new Date(item.dt * 1000); @@ -459,3 +466,4 @@ const RealWeatherWidget = () => { }; export default Dashboard; +