housecall-pro: Add 15 React MCP apps

This commit is contained in:
Jake Shore 2026-02-12 23:57:44 -05:00
parent 2c41d0fb3b
commit 4c546d654a
32 changed files with 3528 additions and 0 deletions

View File

@ -0,0 +1,150 @@
/**
* Customer Grid - Searchable customer directory
*/
import React, { useState, useMemo } from 'react';
import './styles.css';
interface Customer {
id: string;
name: string;
email: string;
phone: string;
address: string;
city: string;
totalJobs: number;
totalRevenue: number;
lastService: string;
status: 'active' | 'inactive' | 'vip';
}
const mockCustomers: Customer[] = [
{ id: 'C-1001', name: 'John Smith', email: 'john.smith@email.com', phone: '(555) 123-4567', address: '123 Main St', city: 'Springfield', totalJobs: 12, totalRevenue: 4500, lastService: '2024-02-10', status: 'active' },
{ id: 'C-1002', name: 'Sarah Johnson', email: 'sarah.j@email.com', phone: '(555) 234-5678', address: '456 Oak Ave', city: 'Riverside', totalJobs: 24, totalRevenue: 12800, lastService: '2024-02-12', status: 'vip' },
{ id: 'C-1003', name: 'Mike Williams', email: 'mike.w@email.com', phone: '(555) 345-6789', address: '789 Pine Dr', city: 'Lakeside', totalJobs: 3, totalRevenue: 890, lastService: '2024-01-15', status: 'active' },
{ id: 'C-1004', name: 'Emily Davis', email: 'emily.davis@email.com', phone: '(555) 456-7890', address: '321 Elm St', city: 'Springfield', totalJobs: 8, totalRevenue: 3200, lastService: '2024-02-08', status: 'active' },
{ id: 'C-1005', name: 'Robert Martinez', email: 'r.martinez@email.com', phone: '(555) 567-8901', address: '654 Maple Ln', city: 'Hillside', totalJobs: 18, totalRevenue: 8900, lastService: '2024-02-11', status: 'vip' },
{ id: 'C-1006', name: 'Lisa Anderson', email: 'lisa.a@email.com', phone: '(555) 678-9012', address: '987 Cedar Ct', city: 'Riverside', totalJobs: 5, totalRevenue: 1750, lastService: '2024-01-28', status: 'active' },
{ id: 'C-1007', name: 'David Thompson', email: 'david.t@email.com', phone: '(555) 789-0123', address: '147 Birch Rd', city: 'Lakeside', totalJobs: 1, totalRevenue: 250, lastService: '2023-12-05', status: 'inactive' },
{ id: 'C-1008', name: 'Jennifer Garcia', email: 'jen.garcia@email.com', phone: '(555) 890-1234', address: '258 Willow Way', city: 'Springfield', totalJobs: 15, totalRevenue: 6200, lastService: '2024-02-09', status: 'active' },
{ id: 'C-1009', name: 'William Brown', email: 'w.brown@email.com', phone: '(555) 901-2345', address: '369 Spruce St', city: 'Hillside', totalJobs: 2, totalRevenue: 450, lastService: '2023-11-20', status: 'inactive' },
{ id: 'C-1010', name: 'Amanda Wilson', email: 'amanda.w@email.com', phone: '(555) 012-3456', address: '741 Ash Blvd', city: 'Riverside', totalJobs: 28, totalRevenue: 15600, lastService: '2024-02-12', status: 'vip' }
];
export default function CustomerGrid({ api }: any) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all');
const cities = useMemo(() => {
const uniqueCities = [...new Set(mockCustomers.map(c => c.city))];
return uniqueCities.sort();
}, []);
const filteredCustomers = useMemo(() => {
return mockCustomers.filter(customer => {
const matchesSearch = searchTerm === '' ||
customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.phone.includes(searchTerm) ||
customer.address.toLowerCase().includes(searchTerm.toLowerCase()) ||
customer.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || customer.status === statusFilter;
const matchesCity = cityFilter === 'all' || customer.city === cityFilter;
return matchesSearch && matchesStatus && matchesCity;
});
}, [searchTerm, statusFilter, cityFilter]);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'active': 'status-active',
'inactive': 'status-inactive',
'vip': 'status-vip'
};
return classes[status] || '';
};
return (
<div className="customer-grid">
<header className="grid-header">
<h1>Customer Directory</h1>
<div className="header-actions">
<input
type="text"
placeholder="Search customers by name, email, phone, or address..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
</header>
<div className="filters">
<div className="filter-group">
<label>Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="vip">VIP</option>
<option value="inactive">Inactive</option>
</select>
</div>
<div className="filter-group">
<label>City</label>
<select value={cityFilter} onChange={(e) => setCityFilter(e.target.value)}>
<option value="all">All Cities</option>
{cities.map(city => (
<option key={city} value={city}>{city}</option>
))}
</select>
</div>
<div className="results-count">
{filteredCustomers.length} customer{filteredCustomers.length !== 1 ? 's' : ''}
</div>
</div>
<div className="table-container">
<table className="customers-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Contact</th>
<th>Location</th>
<th>Total Jobs</th>
<th>Revenue</th>
<th>Last Service</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredCustomers.map(customer => (
<tr key={customer.id}>
<td className="customer-id">{customer.id}</td>
<td className="customer-name">{customer.name}</td>
<td className="contact-info">
<div>{customer.email}</div>
<div className="phone">{customer.phone}</div>
</td>
<td className="location">
<div>{customer.address}</div>
<div className="city">{customer.city}</div>
</td>
<td className="jobs-count">{customer.totalJobs}</td>
<td className="revenue">${customer.totalRevenue.toLocaleString()}</td>
<td>{new Date(customer.lastService).toLocaleDateString()}</td>
<td>
<span className={`status-badge ${getStatusClass(customer.status)}`}>
{customer.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Customer Grid - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,208 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.customer-grid {
max-width: 1600px;
margin: 0 auto;
}
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.grid-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
}
.search-input {
width: 500px;
padding: 12px 16px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
}
.filters {
display: flex;
gap: 20px;
align-items: flex-end;
margin-bottom: 24px;
padding: 20px;
background: #1e293b;
border-radius: 12px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-group label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-group select {
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 14px;
min-width: 180px;
}
.filter-group select:focus {
outline: none;
border-color: #3b82f6;
}
.results-count {
margin-left: auto;
padding: 10px 16px;
background: #0f172a;
border-radius: 6px;
color: #94a3b8;
font-size: 14px;
}
.table-container {
background: #1e293b;
border-radius: 12px;
overflow: hidden;
border: 1px solid #334155;
}
.customers-table {
width: 100%;
border-collapse: collapse;
}
.customers-table thead {
background: #0f172a;
}
.customers-table th {
padding: 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #334155;
}
.customers-table tbody tr {
border-bottom: 1px solid #334155;
transition: background 0.15s;
}
.customers-table tbody tr:hover {
background: rgba(59, 130, 246, 0.05);
cursor: pointer;
}
.customers-table tbody tr:last-child {
border-bottom: none;
}
.customers-table td {
padding: 16px;
font-size: 14px;
color: #e2e8f0;
}
.customer-id {
color: #3b82f6;
font-weight: 600;
}
.customer-name {
font-weight: 600;
color: #f1f5f9;
font-size: 15px;
}
.contact-info {
line-height: 1.6;
}
.contact-info .phone {
color: #94a3b8;
font-size: 13px;
}
.location {
line-height: 1.6;
}
.location .city {
color: #94a3b8;
font-size: 13px;
}
.jobs-count {
font-weight: 600;
color: #60a5fa;
text-align: center;
}
.revenue {
font-weight: 600;
color: #10b981;
text-align: right;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
display: inline-block;
}
.status-active {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-vip {
background: rgba(168, 85, 247, 0.15);
color: #c084fc;
}
.status-inactive {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}

View File

@ -0,0 +1,202 @@
/**
* Dispatch Board - Drag-style dispatch view of technicians and jobs
*/
import React, { useState } from 'react';
import './styles.css';
interface Job {
id: string;
customer: string;
service: string;
time: string;
duration: number;
value: number;
priority: 'low' | 'medium' | 'high';
}
interface Technician {
id: string;
name: string;
status: 'available' | 'busy' | 'offline';
jobs: Job[];
capacity: number;
}
const mockTechnicians: Technician[] = [
{
id: 'T-001',
name: 'Mike Johnson',
status: 'busy',
capacity: 8,
jobs: [
{ id: 'J-1001', customer: 'Smith Residence', service: 'HVAC Repair', time: '09:00', duration: 2, value: 450, priority: 'medium' },
{ id: 'J-1004', customer: 'Martinez Home', service: 'AC Maintenance', time: '14:00', duration: 1.5, value: 150, priority: 'low' }
]
},
{
id: 'T-002',
name: 'Sarah Lee',
status: 'busy',
capacity: 8,
jobs: [
{ id: 'J-1002', customer: 'Downtown Office', service: 'Plumbing Install', time: '10:30', duration: 3, value: 1200, priority: 'high' },
{ id: 'J-1005', customer: 'Riverside Complex', service: 'Water Heater Repair', time: '15:30', duration: 2, value: 380, priority: 'high' }
]
},
{
id: 'T-003',
name: 'Tom Wilson',
status: 'available',
capacity: 8,
jobs: [
{ id: 'J-1003', customer: 'Oak Street Apt', service: 'Electrical Inspection', time: '12:00', duration: 1, value: 200, priority: 'low' }
]
},
{
id: 'T-004',
name: 'Jessica Martinez',
status: 'available',
capacity: 8,
jobs: []
},
{
id: 'T-005',
name: 'David Chen',
status: 'offline',
capacity: 8,
jobs: []
}
];
const unassignedJobs: Job[] = [
{ id: 'J-2001', customer: 'Harbor View', service: 'Drain Cleaning', time: '13:00', duration: 1, value: 175, priority: 'medium' },
{ id: 'J-2002', customer: 'Pine Street House', service: 'Furnace Repair', time: '11:00', duration: 2.5, value: 620, priority: 'high' },
{ id: 'J-2003', customer: 'Valley Office Park', service: 'Commercial HVAC', time: '16:00', duration: 4, value: 2400, priority: 'high' }
];
export default function DispatchBoard({ api }: any) {
const [technicians, setTechnicians] = useState<Technician[]>(mockTechnicians);
const [unassigned, setUnassigned] = useState<Job[]>(unassignedJobs);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'available': 'status-available',
'busy': 'status-busy',
'offline': 'status-offline'
};
return classes[status] || '';
};
const getPriorityClass = (priority: string) => {
const classes: Record<string, string> = {
'low': 'priority-low',
'medium': 'priority-medium',
'high': 'priority-high'
};
return classes[priority] || '';
};
const getTotalHours = (jobs: Job[]) => {
return jobs.reduce((sum, job) => sum + job.duration, 0);
};
const getTotalRevenue = (jobs: Job[]) => {
return jobs.reduce((sum, job) => sum + job.value, 0);
};
return (
<div className="dispatch-board">
<header className="board-header">
<h1>Dispatch Board</h1>
<div className="header-info">
<span className="info-item">
📅 {new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
</span>
<span className="info-item">
{technicians.filter(t => t.status === 'available').length} Available
{technicians.filter(t => t.status === 'busy').length} Busy
{technicians.filter(t => t.status === 'offline').length} Offline
</span>
</div>
</header>
<div className="board-container">
<div className="unassigned-column">
<div className="column-header unassigned-header">
<h2>Unassigned Jobs</h2>
<span className="job-count">{unassigned.length}</span>
</div>
<div className="job-list">
{unassigned.map(job => (
<div key={job.id} className="job-card">
<div className="job-header">
<span className="job-id">{job.id}</span>
<span className={`priority-badge ${getPriorityClass(job.priority)}`}>
{job.priority}
</span>
</div>
<div className="job-customer">{job.customer}</div>
<div className="job-service">{job.service}</div>
<div className="job-meta">
<span>🕐 {job.time}</span>
<span> {job.duration}h</span>
<span className="job-value">${job.value}</span>
</div>
</div>
))}
</div>
</div>
<div className="technicians-columns">
{technicians.map(tech => (
<div key={tech.id} className="tech-column">
<div className={`column-header tech-header ${getStatusClass(tech.status)}`}>
<div className="tech-info">
<h3>{tech.name}</h3>
<span className={`status-badge ${getStatusClass(tech.status)}`}>
{tech.status}
</span>
</div>
<div className="tech-stats">
<div className="stat">
{getTotalHours(tech.jobs)}/{tech.capacity}h
</div>
<div className="stat-label">Hours</div>
</div>
</div>
<div className="job-list">
{tech.jobs.length === 0 ? (
<div className="empty-state">No jobs assigned</div>
) : (
tech.jobs.map(job => (
<div key={job.id} className="job-card">
<div className="job-header">
<span className="job-id">{job.id}</span>
<span className={`priority-badge ${getPriorityClass(job.priority)}`}>
{job.priority}
</span>
</div>
<div className="job-customer">{job.customer}</div>
<div className="job-service">{job.service}</div>
<div className="job-meta">
<span>🕐 {job.time}</span>
<span> {job.duration}h</span>
<span className="job-value">${job.value}</span>
</div>
</div>
))
)}
</div>
<div className="tech-footer">
<div className="revenue-total">
Total: ${getTotalRevenue(tech.jobs).toLocaleString()}
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dispatch Board - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,282 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.dispatch-board {
max-width: 100%;
margin: 0 auto;
}
.board-header {
margin-bottom: 24px;
}
.board-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 8px;
}
.header-info {
display: flex;
gap: 24px;
color: #94a3b8;
font-size: 14px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.board-container {
display: flex;
gap: 20px;
overflow-x: auto;
padding-bottom: 20px;
}
.unassigned-column {
min-width: 320px;
flex-shrink: 0;
}
.technicians-columns {
display: flex;
gap: 20px;
flex: 1;
}
.tech-column {
min-width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
max-height: calc(100vh - 200px);
}
.column-header {
background: #1e293b;
padding: 16px;
border-radius: 12px 12px 0 0;
border: 1px solid #334155;
border-bottom: none;
}
.unassigned-header {
display: flex;
justify-content: space-between;
align-items: center;
background: #1e293b;
border-left: 4px solid #f59e0b;
}
.unassigned-header h2 {
font-size: 18px;
color: #f1f5f9;
}
.job-count {
background: #0f172a;
padding: 4px 12px;
border-radius: 12px;
font-weight: 600;
color: #f59e0b;
}
.tech-header {
border-left: 4px solid #3b82f6;
}
.tech-header.status-available {
border-left-color: #10b981;
}
.tech-header.status-busy {
border-left-color: #f59e0b;
}
.tech-header.status-offline {
border-left-color: #64748b;
}
.tech-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.tech-info h3 {
font-size: 16px;
color: #f1f5f9;
}
.status-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
}
.status-available {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-busy {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-offline {
background: rgba(100, 116, 139, 0.15);
color: #94a3b8;
}
.tech-stats {
text-align: center;
}
.stat {
font-size: 18px;
font-weight: 700;
color: #f1f5f9;
}
.stat-label {
font-size: 11px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.job-list {
background: #1e293b;
border: 1px solid #334155;
border-top: none;
border-radius: 0 0 12px 12px;
padding: 12px;
min-height: 200px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.job-card {
background: #0f172a;
padding: 14px;
border-radius: 8px;
border: 1px solid #334155;
cursor: grab;
transition: all 0.2s;
}
.job-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
}
.job-card:active {
cursor: grabbing;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.job-id {
font-size: 12px;
font-weight: 600;
color: #3b82f6;
}
.priority-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.priority-low {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.priority-medium {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.priority-high {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.job-customer {
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
}
.job-service {
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
}
.job-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #94a3b8;
}
.job-value {
margin-left: auto;
font-weight: 600;
color: #10b981;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #64748b;
font-size: 14px;
}
.tech-footer {
background: #1e293b;
border: 1px solid #334155;
border-top: none;
border-radius: 0 0 12px 12px;
padding: 12px 16px;
margin-top: -12px;
}
.revenue-total {
text-align: center;
font-weight: 600;
color: #10b981;
font-size: 14px;
}

View File

@ -0,0 +1,108 @@
/**
* Job Dashboard - Overview stats and today's schedule
*/
import React, { useState, useEffect } from 'react';
import './styles.css';
interface JobStats {
open: number;
scheduled: number;
completed: number;
inProgress: number;
}
interface TodayJob {
id: string;
customer: string;
service: string;
time: string;
technician: string;
status: 'scheduled' | 'in-progress' | 'completed';
value: number;
}
export default function JobDashboard({ api }: any) {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<JobStats>({
open: 24,
scheduled: 18,
completed: 156,
inProgress: 6
});
const [todayJobs, setTodayJobs] = useState<TodayJob[]>([
{ id: 'J-1001', customer: 'Smith Residence', service: 'HVAC Repair', time: '09:00 AM', technician: 'Mike Johnson', status: 'completed', value: 450 },
{ id: 'J-1002', customer: 'Downtown Office', service: 'Plumbing Install', time: '10:30 AM', technician: 'Sarah Lee', status: 'in-progress', value: 1200 },
{ id: 'J-1003', customer: 'Oak Street Apt', service: 'Electrical Inspection', time: '12:00 PM', technician: 'Tom Wilson', status: 'scheduled', value: 200 },
{ id: 'J-1004', customer: 'Martinez Home', service: 'AC Maintenance', time: '02:00 PM', technician: 'Mike Johnson', status: 'scheduled', value: 150 },
{ id: 'J-1005', customer: 'Riverside Complex', service: 'Water Heater Repair', time: '03:30 PM', technician: 'Sarah Lee', status: 'scheduled', value: 380 }
]);
useEffect(() => {
setTimeout(() => setLoading(false), 500);
}, []);
const getStatusBadge = (status: string) => {
const badges = {
'scheduled': 'badge-scheduled',
'in-progress': 'badge-progress',
'completed': 'badge-completed'
};
return badges[status as keyof typeof badges] || 'badge-scheduled';
};
if (loading) {
return <div className="loading">Loading dashboard...</div>;
}
return (
<div className="job-dashboard">
<header className="dashboard-header">
<h1>Job Dashboard</h1>
<div className="date">{new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</div>
</header>
<div className="stats-grid">
<div className="stat-card stat-open">
<div className="stat-value">{stats.open}</div>
<div className="stat-label">Open Jobs</div>
</div>
<div className="stat-card stat-scheduled">
<div className="stat-value">{stats.scheduled}</div>
<div className="stat-label">Scheduled</div>
</div>
<div className="stat-card stat-progress">
<div className="stat-value">{stats.inProgress}</div>
<div className="stat-label">In Progress</div>
</div>
<div className="stat-card stat-completed">
<div className="stat-value">{stats.completed}</div>
<div className="stat-label">Completed (MTD)</div>
</div>
</div>
<div className="today-schedule">
<h2>Today's Schedule</h2>
<div className="job-list">
{todayJobs.map(job => (
<div key={job.id} className="job-card">
<div className="job-header">
<div className="job-id">{job.id}</div>
<span className={`badge ${getStatusBadge(job.status)}`}>
{job.status.replace('-', ' ')}
</span>
</div>
<div className="job-customer">{job.customer}</div>
<div className="job-service">{job.service}</div>
<div className="job-footer">
<div className="job-time">🕐 {job.time}</div>
<div className="job-tech">👤 {job.technician}</div>
<div className="job-value">${job.value}</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Job Dashboard - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,170 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #64748b;
}
.job-dashboard {
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
margin-bottom: 32px;
}
.dashboard-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 8px;
}
.date {
color: #94a3b8;
font-size: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 40px;
}
.stat-card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border-left: 4px solid #3b82f6;
}
.stat-card.stat-open { border-left-color: #3b82f6; }
.stat-card.stat-scheduled { border-left-color: #8b5cf6; }
.stat-card.stat-progress { border-left-color: #f59e0b; }
.stat-card.stat-completed { border-left-color: #10b981; }
.stat-value {
font-size: 36px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 8px;
}
.stat-label {
color: #94a3b8;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.today-schedule h2 {
font-size: 24px;
margin-bottom: 20px;
color: #f1f5f9;
}
.job-list {
display: grid;
gap: 16px;
}
.job-card {
background: #1e293b;
padding: 20px;
border-radius: 12px;
border: 1px solid #334155;
transition: all 0.2s;
}
.job-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.job-id {
font-weight: 600;
color: #3b82f6;
font-size: 14px;
}
.badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.badge-scheduled {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}
.badge-progress {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.badge-completed {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.job-customer {
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
}
.job-service {
color: #94a3b8;
font-size: 14px;
margin-bottom: 12px;
}
.job-footer {
display: flex;
gap: 20px;
align-items: center;
padding-top: 12px;
border-top: 1px solid #334155;
font-size: 13px;
}
.job-time,
.job-tech {
color: #94a3b8;
}
.job-value {
margin-left: auto;
font-weight: 600;
color: #10b981;
font-size: 16px;
}

View File

@ -0,0 +1,153 @@
/**
* Job Grid - Searchable/filterable table of all jobs
*/
import React, { useState, useMemo } from 'react';
import './styles.css';
interface Job {
id: string;
customer: string;
service: string;
date: string;
technician: string;
status: 'open' | 'scheduled' | 'in-progress' | 'completed' | 'cancelled';
value: number;
priority: 'low' | 'medium' | 'high';
}
const mockJobs: Job[] = [
{ id: 'J-1001', customer: 'Smith Residence', service: 'HVAC Repair', date: '2024-02-12', technician: 'Mike Johnson', status: 'completed', value: 450, priority: 'medium' },
{ id: 'J-1002', customer: 'Downtown Office', service: 'Plumbing Install', date: '2024-02-12', technician: 'Sarah Lee', status: 'in-progress', value: 1200, priority: 'high' },
{ id: 'J-1003', customer: 'Oak Street Apt', service: 'Electrical Inspection', date: '2024-02-12', technician: 'Tom Wilson', status: 'scheduled', value: 200, priority: 'low' },
{ id: 'J-1004', customer: 'Martinez Home', service: 'AC Maintenance', date: '2024-02-13', technician: 'Mike Johnson', status: 'scheduled', value: 150, priority: 'medium' },
{ id: 'J-1005', customer: 'Riverside Complex', service: 'Water Heater Repair', date: '2024-02-13', technician: 'Sarah Lee', status: 'open', value: 380, priority: 'high' },
{ id: 'J-1006', customer: 'Harbor View', service: 'Drain Cleaning', date: '2024-02-14', technician: 'Tom Wilson', status: 'scheduled', value: 175, priority: 'low' },
{ id: 'J-1007', customer: 'Pine Street House', service: 'Furnace Repair', date: '2024-02-14', technician: 'Mike Johnson', status: 'completed', value: 620, priority: 'high' },
{ id: 'J-1008', customer: 'Valley Office Park', service: 'Commercial HVAC', date: '2024-02-15', technician: 'Sarah Lee', status: 'scheduled', value: 2400, priority: 'high' },
{ id: 'J-1009', customer: 'Johnson Residence', service: 'Pipe Inspection', date: '2024-02-10', technician: 'Tom Wilson', status: 'completed', value: 220, priority: 'medium' },
{ id: 'J-1010', customer: 'City Hall', service: 'Electrical Upgrade', date: '2024-02-09', technician: 'Mike Johnson', status: 'cancelled', value: 0, priority: 'low' }
];
export default function JobGrid({ api }: any) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const filteredJobs = useMemo(() => {
return mockJobs.filter(job => {
const matchesSearch = searchTerm === '' ||
job.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.service.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.technician.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || job.status === statusFilter;
const matchesPriority = priorityFilter === 'all' || job.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
}, [searchTerm, statusFilter, priorityFilter]);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'open': 'status-open',
'scheduled': 'status-scheduled',
'in-progress': 'status-progress',
'completed': 'status-completed',
'cancelled': 'status-cancelled'
};
return classes[status] || '';
};
const getPriorityClass = (priority: string) => {
const classes: Record<string, string> = {
'low': 'priority-low',
'medium': 'priority-medium',
'high': 'priority-high'
};
return classes[priority] || '';
};
return (
<div className="job-grid">
<header className="grid-header">
<h1>All Jobs</h1>
<div className="header-actions">
<input
type="text"
placeholder="Search jobs, customers, or technicians..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
</header>
<div className="filters">
<div className="filter-group">
<label>Status</label>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="open">Open</option>
<option value="scheduled">Scheduled</option>
<option value="in-progress">In Progress</option>
<option value="completed">Completed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div className="filter-group">
<label>Priority</label>
<select value={priorityFilter} onChange={(e) => setPriorityFilter(e.target.value)}>
<option value="all">All Priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div className="results-count">
{filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''}
</div>
</div>
<div className="table-container">
<table className="jobs-table">
<thead>
<tr>
<th>Job ID</th>
<th>Customer</th>
<th>Service</th>
<th>Date</th>
<th>Technician</th>
<th>Status</th>
<th>Priority</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{filteredJobs.map(job => (
<tr key={job.id}>
<td className="job-id">{job.id}</td>
<td className="customer-name">{job.customer}</td>
<td>{job.service}</td>
<td>{new Date(job.date).toLocaleDateString()}</td>
<td>{job.technician}</td>
<td>
<span className={`status-badge ${getStatusClass(job.status)}`}>
{job.status.replace('-', ' ')}
</span>
</td>
<td>
<span className={`priority-badge ${getPriorityClass(job.priority)}`}>
{job.priority}
</span>
</td>
<td className="value">${job.value.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Job Grid - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,208 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.job-grid {
max-width: 1600px;
margin: 0 auto;
}
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.grid-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
}
.search-input {
width: 400px;
padding: 12px 16px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
}
.filters {
display: flex;
gap: 20px;
align-items: flex-end;
margin-bottom: 24px;
padding: 20px;
background: #1e293b;
border-radius: 12px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-group label {
font-size: 12px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-group select {
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 14px;
min-width: 180px;
}
.filter-group select:focus {
outline: none;
border-color: #3b82f6;
}
.results-count {
margin-left: auto;
padding: 10px 16px;
background: #0f172a;
border-radius: 6px;
color: #94a3b8;
font-size: 14px;
}
.table-container {
background: #1e293b;
border-radius: 12px;
overflow: hidden;
border: 1px solid #334155;
}
.jobs-table {
width: 100%;
border-collapse: collapse;
}
.jobs-table thead {
background: #0f172a;
}
.jobs-table th {
padding: 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #334155;
}
.jobs-table tbody tr {
border-bottom: 1px solid #334155;
transition: background 0.15s;
}
.jobs-table tbody tr:hover {
background: rgba(59, 130, 246, 0.05);
}
.jobs-table tbody tr:last-child {
border-bottom: none;
}
.jobs-table td {
padding: 16px;
font-size: 14px;
color: #e2e8f0;
}
.job-id {
color: #3b82f6;
font-weight: 600;
}
.customer-name {
font-weight: 500;
color: #f1f5f9;
}
.status-badge,
.priority-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
display: inline-block;
}
.status-open {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.status-scheduled {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}
.status-progress {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-completed {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-cancelled {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.priority-low {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.priority-medium {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.priority-high {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.value {
font-weight: 600;
color: #10b981;
text-align: right;
}

View File

@ -0,0 +1,181 @@
/**
* Payment History - Payment log with filters by date, customer, method
*/
import React, { useState, useMemo } from 'react';
import './styles.css';
interface Payment {
id: string;
date: string;
customer: string;
jobId: string;
amount: number;
method: 'credit-card' | 'cash' | 'check' | 'ach' | 'other';
status: 'completed' | 'pending' | 'failed' | 'refunded';
invoice: string;
}
const mockPayments: Payment[] = [
{ id: 'P-1001', date: '2024-02-12', customer: 'John Smith', jobId: 'J-1001', amount: 450, method: 'credit-card', status: 'completed', invoice: 'INV-5001' },
{ id: 'P-1002', date: '2024-02-12', customer: 'Sarah Johnson', jobId: 'J-1002', amount: 1200, method: 'ach', status: 'completed', invoice: 'INV-5002' },
{ id: 'P-1003', date: '2024-02-11', customer: 'Mike Williams', jobId: 'J-998', amount: 300, method: 'cash', status: 'completed', invoice: 'INV-4998' },
{ id: 'P-1004', date: '2024-02-11', customer: 'Emily Davis', jobId: 'J-997', amount: 850, method: 'credit-card', status: 'completed', invoice: 'INV-4997' },
{ id: 'P-1005', date: '2024-02-10', customer: 'Robert Martinez', jobId: 'J-995', amount: 2400, method: 'check', status: 'pending', invoice: 'INV-4995' },
{ id: 'P-1006', date: '2024-02-10', customer: 'Lisa Anderson', jobId: 'J-993', amount: 175, method: 'credit-card', status: 'completed', invoice: 'INV-4993' },
{ id: 'P-1007', date: '2024-02-09', customer: 'David Thompson', jobId: 'J-990', amount: 620, method: 'credit-card', status: 'completed', invoice: 'INV-4990' },
{ id: 'P-1008', date: '2024-02-09', customer: 'Jennifer Garcia', jobId: 'J-988', amount: 500, method: 'cash', status: 'completed', invoice: 'INV-4988' },
{ id: 'P-1009', date: '2024-02-08', customer: 'William Brown', jobId: 'J-985', amount: 1100, method: 'ach', status: 'failed', invoice: 'INV-4985' },
{ id: 'P-1010', date: '2024-02-08', customer: 'Amanda Wilson', jobId: 'J-983', amount: 380, method: 'credit-card', status: 'completed', invoice: 'INV-4983' },
{ id: 'P-1011', date: '2024-02-07', customer: 'James Lee', jobId: 'J-980', amount: 225, method: 'cash', status: 'completed', invoice: 'INV-4980' },
{ id: 'P-1012', date: '2024-02-07', customer: 'Maria Rodriguez', jobId: 'J-978', amount: 950, method: 'credit-card', status: 'refunded', invoice: 'INV-4978' },
{ id: 'P-1013', date: '2024-02-06', customer: 'Thomas Clark', jobId: 'J-975', amount: 1500, method: 'ach', status: 'completed', invoice: 'INV-4975' },
{ id: 'P-1014', date: '2024-02-06', customer: 'Patricia Miller', jobId: 'J-972', amount: 420, method: 'check', status: 'completed', invoice: 'INV-4972' },
{ id: 'P-1015', date: '2024-02-05', customer: 'Christopher White', jobId: 'J-970', amount: 680, method: 'credit-card', status: 'completed', invoice: 'INV-4970' }
];
export default function PaymentHistory({ api }: any) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [methodFilter, setMethodFilter] = useState('all');
const [dateFilter, setDateFilter] = useState('all');
const filteredPayments = useMemo(() => {
return mockPayments.filter(payment => {
const matchesSearch = searchTerm === '' ||
payment.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.jobId.toLowerCase().includes(searchTerm.toLowerCase()) ||
payment.invoice.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || payment.status === statusFilter;
const matchesMethod = methodFilter === 'all' || payment.method === methodFilter;
let matchesDate = true;
if (dateFilter !== 'all') {
const paymentDate = new Date(payment.date);
const today = new Date();
const daysDiff = Math.floor((today.getTime() - paymentDate.getTime()) / (1000 * 60 * 60 * 24));
if (dateFilter === 'today') matchesDate = daysDiff === 0;
else if (dateFilter === 'week') matchesDate = daysDiff <= 7;
else if (dateFilter === 'month') matchesDate = daysDiff <= 30;
}
return matchesSearch && matchesStatus && matchesMethod && matchesDate;
});
}, [searchTerm, statusFilter, methodFilter, dateFilter]);
const totalAmount = useMemo(() => {
return filteredPayments
.filter(p => p.status === 'completed')
.reduce((sum, p) => sum + p.amount, 0);
}, [filteredPayments]);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'completed': 'status-completed',
'pending': 'status-pending',
'failed': 'status-failed',
'refunded': 'status-refunded'
};
return classes[status] || '';
};
const getMethodIcon = (method: string) => {
const icons: Record<string, string> = {
'credit-card': '💳',
'cash': '💵',
'check': '📝',
'ach': '🏦',
'other': '💰'
};
return icons[method] || '💰';
};
return (
<div className="payment-history">
<header className="history-header">
<h1>Payment History</h1>
<div className="total-banner">
Total (Filtered): <span className="total-amount">${totalAmount.toLocaleString()}</span>
</div>
</header>
<div className="controls">
<input
type="text"
placeholder="Search by customer, payment ID, job ID, or invoice..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<div className="filters">
<select value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
<option value="all">All Time</option>
<option value="today">Today</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
</select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
<option value="refunded">Refunded</option>
</select>
<select value={methodFilter} onChange={(e) => setMethodFilter(e.target.value)}>
<option value="all">All Methods</option>
<option value="credit-card">Credit Card</option>
<option value="cash">Cash</option>
<option value="check">Check</option>
<option value="ach">ACH</option>
<option value="other">Other</option>
</select>
</div>
<div className="results-count">
{filteredPayments.length} payment{filteredPayments.length !== 1 ? 's' : ''}
</div>
</div>
<div className="table-container">
<table className="payments-table">
<thead>
<tr>
<th>Payment ID</th>
<th>Date</th>
<th>Customer</th>
<th>Job ID</th>
<th>Invoice</th>
<th>Method</th>
<th>Amount</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{filteredPayments.map(payment => (
<tr key={payment.id}>
<td className="payment-id">{payment.id}</td>
<td>{new Date(payment.date).toLocaleDateString()}</td>
<td className="customer-name">{payment.customer}</td>
<td className="job-id">{payment.jobId}</td>
<td className="invoice-id">{payment.invoice}</td>
<td>
<span className="payment-method">
{getMethodIcon(payment.method)} {payment.method.replace('-', ' ')}
</span>
</td>
<td className="amount">${payment.amount.toLocaleString()}</td>
<td>
<span className={`status-badge ${getStatusClass(payment.status)}`}>
{payment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Payment History - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,209 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.payment-history {
max-width: 1600px;
margin: 0 auto;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.history-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
}
.total-banner {
background: linear-gradient(135deg, #10b981, #059669);
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
color: white;
}
.total-amount {
font-size: 24px;
font-weight: 700;
margin-left: 8px;
}
.controls {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
padding: 20px;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
}
.search-input {
width: 100%;
padding: 12px 16px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
}
.filters {
display: flex;
gap: 12px;
}
.filters select {
flex: 1;
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 14px;
}
.filters select:focus {
outline: none;
border-color: #3b82f6;
}
.results-count {
text-align: right;
color: #94a3b8;
font-size: 14px;
}
.table-container {
background: #1e293b;
border-radius: 12px;
overflow: hidden;
border: 1px solid #334155;
}
.payments-table {
width: 100%;
border-collapse: collapse;
}
.payments-table thead {
background: #0f172a;
}
.payments-table th {
padding: 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #334155;
}
.payments-table tbody tr {
border-bottom: 1px solid #334155;
transition: background 0.15s;
}
.payments-table tbody tr:hover {
background: rgba(59, 130, 246, 0.05);
cursor: pointer;
}
.payments-table tbody tr:last-child {
border-bottom: none;
}
.payments-table td {
padding: 16px;
font-size: 14px;
color: #e2e8f0;
}
.payment-id {
color: #3b82f6;
font-weight: 600;
}
.customer-name {
font-weight: 500;
color: #f1f5f9;
}
.job-id,
.invoice-id {
color: #8b5cf6;
font-weight: 500;
font-size: 13px;
}
.payment-method {
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: capitalize;
color: #94a3b8;
}
.amount {
font-weight: 700;
color: #10b981;
text-align: right;
font-size: 15px;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
display: inline-block;
}
.status-completed {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-pending {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-failed {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.status-refunded {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}

View File

@ -0,0 +1,192 @@
/**
* Revenue Dashboard - Revenue charts by period, service type, technician
*/
import React, { useState } from 'react';
import './styles.css';
interface RevenueData {
today: number;
week: number;
month: number;
year: number;
}
interface ServiceRevenue {
service: string;
revenue: number;
jobs: number;
percentage: number;
}
interface TechRevenue {
name: string;
revenue: number;
jobs: number;
}
interface DailyRevenue {
date: string;
revenue: number;
}
const revenueData: RevenueData = {
today: 3850,
week: 28450,
month: 124600,
year: 892350
};
const serviceRevenue: ServiceRevenue[] = [
{ service: 'HVAC Repair', revenue: 45200, jobs: 82, percentage: 36.3 },
{ service: 'Plumbing', revenue: 32800, jobs: 104, percentage: 26.3 },
{ service: 'Electrical', revenue: 28400, jobs: 96, percentage: 22.8 },
{ service: 'Maintenance', revenue: 12600, jobs: 142, percentage: 10.1 },
{ service: 'Installation', revenue: 5600, jobs: 12, percentage: 4.5 }
];
const techRevenue: TechRevenue[] = [
{ name: 'Mike Johnson', revenue: 38450, jobs: 124 },
{ name: 'Sarah Lee', revenue: 42800, jobs: 136 },
{ name: 'Tom Wilson', revenue: 28900, jobs: 98 },
{ name: 'Jessica Martinez', revenue: 14450, jobs: 78 }
];
const dailyRevenue: DailyRevenue[] = [
{ date: '02/06', revenue: 18200 },
{ date: '02/07', revenue: 21500 },
{ date: '02/08', revenue: 16800 },
{ date: '02/09', revenue: 24300 },
{ date: '02/10', revenue: 19700 },
{ date: '02/11', revenue: 22900 },
{ date: '02/12', revenue: 20150 }
];
export default function RevenueDashboard({ api }: any) {
const [period, setPeriod] = useState<'today' | 'week' | 'month' | 'year'>('month');
const maxDailyRevenue = Math.max(...dailyRevenue.map(d => d.revenue));
return (
<div className="revenue-dashboard">
<header className="dashboard-header">
<h1>Revenue Dashboard</h1>
<div className="period-selector">
<button
className={period === 'today' ? 'active' : ''}
onClick={() => setPeriod('today')}
>
Today
</button>
<button
className={period === 'week' ? 'active' : ''}
onClick={() => setPeriod('week')}
>
Week
</button>
<button
className={period === 'month' ? 'active' : ''}
onClick={() => setPeriod('month')}
>
Month
</button>
<button
className={period === 'year' ? 'active' : ''}
onClick={() => setPeriod('year')}
>
Year
</button>
</div>
</header>
<div className="revenue-stats">
<div className="stat-card stat-today">
<div className="stat-label">Today</div>
<div className="stat-value">${revenueData.today.toLocaleString()}</div>
<div className="stat-change positive">+12.5%</div>
</div>
<div className="stat-card stat-week">
<div className="stat-label">This Week</div>
<div className="stat-value">${revenueData.week.toLocaleString()}</div>
<div className="stat-change positive">+8.3%</div>
</div>
<div className="stat-card stat-month">
<div className="stat-label">This Month</div>
<div className="stat-value">${revenueData.month.toLocaleString()}</div>
<div className="stat-change positive">+15.7%</div>
</div>
<div className="stat-card stat-year">
<div className="stat-label">This Year</div>
<div className="stat-value">${revenueData.year.toLocaleString()}</div>
<div className="stat-change positive">+22.1%</div>
</div>
</div>
<div className="charts-grid">
<div className="chart-card daily-chart">
<h2>Daily Revenue (Last 7 Days)</h2>
<div className="bar-chart">
{dailyRevenue.map(day => (
<div key={day.date} className="bar-group">
<div className="bar-container">
<div
className="bar"
style={{ height: `${(day.revenue / maxDailyRevenue) * 100}%` }}
>
<div className="bar-value">${(day.revenue / 1000).toFixed(1)}k</div>
</div>
</div>
<div className="bar-label">{day.date}</div>
</div>
))}
</div>
</div>
<div className="chart-card service-chart">
<h2>Revenue by Service Type</h2>
<div className="service-breakdown">
{serviceRevenue.map((service, idx) => (
<div key={idx} className="service-row">
<div className="service-info">
<div className="service-name">{service.service}</div>
<div className="service-meta">
{service.jobs} jobs
</div>
</div>
<div className="service-bar-container">
<div
className="service-bar"
style={{ width: `${service.percentage}%` }}
/>
</div>
<div className="service-revenue">
${service.revenue.toLocaleString()}
</div>
</div>
))}
</div>
</div>
</div>
<div className="tech-performance">
<h2>Technician Performance</h2>
<div className="tech-grid">
{techRevenue.map((tech, idx) => (
<div key={idx} className="tech-card">
<div className="tech-avatar">
{tech.name.split(' ').map(n => n[0]).join('')}
</div>
<div className="tech-info">
<div className="tech-name">{tech.name}</div>
<div className="tech-stats">
<div className="tech-revenue">${tech.revenue.toLocaleString()}</div>
<div className="tech-jobs">{tech.jobs} jobs</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Revenue Dashboard - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,310 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.revenue-dashboard {
max-width: 1400px;
margin: 0 auto;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.dashboard-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
}
.period-selector {
display: flex;
gap: 8px;
background: #1e293b;
padding: 4px;
border-radius: 8px;
}
.period-selector button {
padding: 8px 20px;
background: transparent;
border: none;
border-radius: 6px;
color: #94a3b8;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.period-selector button:hover {
color: #e2e8f0;
}
.period-selector button.active {
background: #3b82f6;
color: white;
}
.revenue-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border-left: 4px solid #3b82f6;
}
.stat-card.stat-today { border-left-color: #10b981; }
.stat-card.stat-week { border-left-color: #3b82f6; }
.stat-card.stat-month { border-left-color: #8b5cf6; }
.stat-card.stat-year { border-left-color: #f59e0b; }
.stat-label {
font-size: 13px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 8px;
}
.stat-change {
font-size: 14px;
font-weight: 600;
}
.stat-change.positive {
color: #10b981;
}
.stat-change.negative {
color: #ef4444;
}
.charts-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
}
.chart-card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
}
.chart-card h2 {
font-size: 18px;
color: #f1f5f9;
margin-bottom: 24px;
}
.bar-chart {
display: flex;
gap: 12px;
align-items: flex-end;
height: 240px;
padding-top: 20px;
}
.bar-group {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bar-container {
width: 100%;
height: 200px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 100%;
background: linear-gradient(180deg, #3b82f6, #1e40af);
border-radius: 6px 6px 0 0;
position: relative;
min-height: 20px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8px;
transition: all 0.3s;
}
.bar:hover {
opacity: 0.8;
}
.bar-value {
font-size: 11px;
font-weight: 600;
color: white;
}
.bar-label {
font-size: 12px;
color: #94a3b8;
}
.service-breakdown {
display: flex;
flex-direction: column;
gap: 16px;
}
.service-row {
display: grid;
grid-template-columns: 200px 1fr 120px;
gap: 16px;
align-items: center;
}
.service-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.service-name {
font-weight: 600;
color: #f1f5f9;
font-size: 14px;
}
.service-meta {
font-size: 12px;
color: #64748b;
}
.service-bar-container {
background: #0f172a;
height: 32px;
border-radius: 6px;
overflow: hidden;
}
.service-bar {
height: 100%;
background: linear-gradient(90deg, #8b5cf6, #6d28d9);
border-radius: 6px;
transition: width 0.5s ease;
}
.service-revenue {
font-weight: 700;
color: #10b981;
text-align: right;
font-size: 16px;
}
.tech-performance {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
}
.tech-performance h2 {
font-size: 20px;
color: #f1f5f9;
margin-bottom: 24px;
}
.tech-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.tech-card {
display: flex;
gap: 16px;
padding: 16px;
background: #0f172a;
border-radius: 8px;
border: 1px solid #334155;
transition: all 0.2s;
}
.tech-card:hover {
border-color: #3b82f6;
transform: translateY(-2px);
}
.tech-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
color: white;
flex-shrink: 0;
}
.tech-info {
flex: 1;
}
.tech-name {
font-weight: 600;
color: #f1f5f9;
margin-bottom: 8px;
font-size: 15px;
}
.tech-stats {
display: flex;
gap: 16px;
align-items: baseline;
}
.tech-revenue {
font-size: 20px;
font-weight: 700;
color: #10b981;
}
.tech-jobs {
font-size: 13px;
color: #64748b;
}
@media (max-width: 1024px) {
.charts-grid {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,198 @@
/**
* Review Tracker - Customer reviews with ratings, response status
*/
import React, { useState, useMemo } from 'react';
import './styles.css';
interface Review {
id: string;
customer: string;
technician: string;
jobId: string;
rating: number;
date: string;
comment: string;
response?: string;
responseDate?: string;
status: 'pending' | 'responded' | 'flagged';
source: 'google' | 'yelp' | 'facebook' | 'internal';
}
const mockReviews: Review[] = [
{ id: 'R-1001', customer: 'Sarah Wilson', technician: 'Mike Johnson', jobId: 'J-998', rating: 5, date: '2024-02-12', comment: 'Mike was professional and fixed our AC quickly! Highly recommend.', status: 'pending', source: 'google' },
{ id: 'R-1002', customer: 'John Davis', technician: 'Sarah Lee', jobId: 'J-995', rating: 5, date: '2024-02-11', comment: 'Excellent service, very knowledgeable. Will definitely use again.', response: 'Thank you for your kind words! We appreciate your business.', responseDate: '2024-02-11', status: 'responded', source: 'yelp' },
{ id: 'R-1003', customer: 'Emily Chen', technician: 'Tom Wilson', jobId: 'J-992', rating: 4, date: '2024-02-11', comment: 'Good work, arrived on time. Would have been 5 stars if the price was a bit lower.', status: 'pending', source: 'google' },
{ id: 'R-1004', customer: 'Robert Martinez', technician: 'Mike Johnson', jobId: 'J-989', rating: 5, date: '2024-02-10', comment: 'Outstanding! Will request Mike again for all future work.', response: 'We are thrilled to hear about your experience! Mike will be happy to assist you again.', responseDate: '2024-02-10', status: 'responded', source: 'facebook' },
{ id: 'R-1005', customer: 'Lisa Taylor', technician: 'Sarah Lee', jobId: 'J-985', rating: 2, date: '2024-02-09', comment: 'Technician was late and the job took longer than estimated. Not happy with the experience.', status: 'flagged', source: 'google' },
{ id: 'R-1006', customer: 'David Brown', technician: 'Tom Wilson', jobId: 'J-982', rating: 5, date: '2024-02-09', comment: 'Perfect service from start to finish. Very satisfied!', response: 'Thank you for choosing us! We look forward to serving you again.', responseDate: '2024-02-09', status: 'responded', source: 'internal' },
{ id: 'R-1007', customer: 'Jennifer White', technician: 'Mike Johnson', jobId: 'J-978', rating: 4, date: '2024-02-08', comment: 'Great technician, minor issue with scheduling but overall good experience.', status: 'pending', source: 'yelp' },
{ id: 'R-1008', customer: 'Michael Garcia', technician: 'Sarah Lee', jobId: 'J-975', rating: 5, date: '2024-02-07', comment: 'Sarah went above and beyond. Explained everything clearly.', response: 'We appreciate your feedback! Sarah takes pride in her work.', responseDate: '2024-02-07', status: 'responded', source: 'google' },
{ id: 'R-1009', customer: 'Amanda Clark', technician: 'Tom Wilson', jobId: 'J-970', rating: 3, date: '2024-02-06', comment: 'Service was okay, nothing special. Expected more based on reviews.', status: 'pending', source: 'facebook' },
{ id: 'R-1010', customer: 'Christopher Lee', technician: 'Mike Johnson', jobId: 'J-968', rating: 5, date: '2024-02-05', comment: 'Fantastic work! Mike is a true professional.', response: 'Thank you! We are glad Mike could help you.', responseDate: '2024-02-06', status: 'responded', source: 'google' }
];
export default function ReviewTracker({ api }: any) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [ratingFilter, setRatingFilter] = useState('all');
const [sourceFilter, setSourceFilter] = useState('all');
const filteredReviews = useMemo(() => {
return mockReviews.filter(review => {
const matchesSearch = searchTerm === '' ||
review.customer.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.technician.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.comment.toLowerCase().includes(searchTerm.toLowerCase()) ||
review.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || review.status === statusFilter;
const matchesRating = ratingFilter === 'all' || review.rating === parseInt(ratingFilter);
const matchesSource = sourceFilter === 'all' || review.source === sourceFilter;
return matchesSearch && matchesStatus && matchesRating && matchesSource;
});
}, [searchTerm, statusFilter, ratingFilter, sourceFilter]);
const averageRating = useMemo(() => {
if (filteredReviews.length === 0) return 0;
return (filteredReviews.reduce((sum, r) => sum + r.rating, 0) / filteredReviews.length).toFixed(1);
}, [filteredReviews]);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'pending': 'status-pending',
'responded': 'status-responded',
'flagged': 'status-flagged'
};
return classes[status] || '';
};
const getSourceClass = (source: string) => {
const classes: Record<string, string> = {
'google': 'source-google',
'yelp': 'source-yelp',
'facebook': 'source-facebook',
'internal': 'source-internal'
};
return classes[source] || '';
};
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<span key={i} className={i < rating ? 'star filled' : 'star'}></span>
));
};
return (
<div className="review-tracker">
<header className="tracker-header">
<div>
<h1>Review Tracker</h1>
<div className="header-stats">
<span className="stat-item">
{averageRating} Average Rating
</span>
<span className="stat-item">
📝 {filteredReviews.length} Reviews
</span>
<span className="stat-item">
{filteredReviews.filter(r => r.status === 'pending').length} Pending Response
</span>
</div>
</div>
</header>
<div className="controls">
<input
type="text"
placeholder="Search by customer, technician, or review content..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
<div className="filters">
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="all">All Status</option>
<option value="pending">Pending</option>
<option value="responded">Responded</option>
<option value="flagged">Flagged</option>
</select>
<select value={ratingFilter} onChange={(e) => setRatingFilter(e.target.value)}>
<option value="all">All Ratings</option>
<option value="5">5 Stars</option>
<option value="4">4 Stars</option>
<option value="3">3 Stars</option>
<option value="2">2 Stars</option>
<option value="1">1 Star</option>
</select>
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
<option value="all">All Sources</option>
<option value="google">Google</option>
<option value="yelp">Yelp</option>
<option value="facebook">Facebook</option>
<option value="internal">Internal</option>
</select>
</div>
</div>
<div className="reviews-container">
{filteredReviews.map(review => (
<div key={review.id} className={`review-card ${getStatusClass(review.status)}`}>
<div className="review-header">
<div className="review-meta">
<div className="customer-name">{review.customer}</div>
<div className="review-rating">{renderStars(review.rating)}</div>
</div>
<div className="review-badges">
<span className={`source-badge ${getSourceClass(review.source)}`}>
{review.source}
</span>
<span className={`status-badge ${getStatusClass(review.status)}`}>
{review.status}
</span>
</div>
</div>
<div className="review-info">
<span className="review-id">{review.id}</span>
<span className="separator"></span>
<span>Technician: {review.technician}</span>
<span className="separator"></span>
<span>Job: {review.jobId}</span>
<span className="separator"></span>
<span>{new Date(review.date).toLocaleDateString()}</span>
</div>
<div className="review-comment">
{review.comment}
</div>
{review.response && (
<div className="review-response">
<div className="response-header">
<span className="response-label">Response</span>
{review.responseDate && (
<span className="response-date">
{new Date(review.responseDate).toLocaleDateString()}
</span>
)}
</div>
<div className="response-text">{review.response}</div>
</div>
)}
{!review.response && review.status === 'pending' && (
<div className="review-actions">
<button className="btn-respond">Respond</button>
{review.rating <= 3 && (
<button className="btn-flag">Flag for Review</button>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Review Tracker - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,296 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.review-tracker {
max-width: 1200px;
margin: 0 auto;
}
.tracker-header {
margin-bottom: 32px;
}
.tracker-header h1 {
font-size: 32px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 12px;
}
.header-stats {
display: flex;
gap: 24px;
color: #94a3b8;
font-size: 14px;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.controls {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
padding: 20px;
background: #1e293b;
border-radius: 12px;
border: 1px solid #334155;
}
.search-input {
width: 100%;
padding: 12px 16px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 8px;
color: #e2e8f0;
font-size: 14px;
}
.search-input:focus {
outline: none;
border-color: #3b82f6;
}
.search-input::placeholder {
color: #64748b;
}
.filters {
display: flex;
gap: 12px;
}
.filters select {
flex: 1;
padding: 10px 14px;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #e2e8f0;
font-size: 14px;
}
.filters select:focus {
outline: none;
border-color: #3b82f6;
}
.reviews-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.review-card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
border-left: 4px solid #3b82f6;
}
.review-card.status-pending {
border-left-color: #f59e0b;
}
.review-card.status-responded {
border-left-color: #10b981;
}
.review-card.status-flagged {
border-left-color: #ef4444;
background: rgba(239, 68, 68, 0.05);
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.review-meta {
display: flex;
flex-direction: column;
gap: 8px;
}
.customer-name {
font-size: 18px;
font-weight: 600;
color: #f1f5f9;
}
.review-rating {
display: flex;
gap: 2px;
}
.star {
color: #334155;
font-size: 18px;
}
.star.filled {
color: #fbbf24;
}
.review-badges {
display: flex;
gap: 8px;
}
.source-badge,
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: capitalize;
}
.source-google {
background: rgba(234, 67, 53, 0.15);
color: #f87171;
}
.source-yelp {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.source-facebook {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
.source-internal {
background: rgba(139, 92, 246, 0.15);
color: #a78bfa;
}
.status-pending {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-responded {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-flagged {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.review-info {
display: flex;
gap: 8px;
align-items: center;
font-size: 13px;
color: #64748b;
margin-bottom: 16px;
}
.review-id {
color: #3b82f6;
font-weight: 600;
}
.separator {
color: #334155;
}
.review-comment {
font-size: 15px;
line-height: 1.6;
color: #e2e8f0;
margin-bottom: 16px;
}
.review-response {
background: #0f172a;
padding: 16px;
border-radius: 8px;
border-left: 3px solid #10b981;
margin-top: 16px;
}
.response-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.response-label {
font-size: 12px;
font-weight: 600;
color: #10b981;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.response-date {
font-size: 12px;
color: #64748b;
}
.response-text {
font-size: 14px;
line-height: 1.6;
color: #94a3b8;
}
.review-actions {
display: flex;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #334155;
}
.review-actions button {
padding: 8px 16px;
border-radius: 6px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-respond {
background: #3b82f6;
color: white;
}
.btn-respond:hover {
background: #2563eb;
}
.btn-flag {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid #ef4444;
}
.btn-flag:hover {
background: rgba(239, 68, 68, 0.2);
}

View File

@ -0,0 +1,177 @@
/**
* Technician Dashboard - Individual tech's schedule, performance, reviews
*/
import React, { useState } from 'react';
import './styles.css';
interface TechnicianData {
id: string;
name: string;
status: 'available' | 'on-job' | 'offline';
rating: number;
totalReviews: number;
jobsCompleted: number;
revenueThisMonth: number;
hoursWorked: number;
}
interface TodayJob {
id: string;
customer: string;
service: string;
time: string;
status: 'upcoming' | 'in-progress' | 'completed';
value: number;
address: string;
}
interface Review {
id: string;
customer: string;
rating: number;
comment: string;
date: string;
jobId: string;
}
const mockTechnician: TechnicianData = {
id: 'T-001',
name: 'Mike Johnson',
status: 'on-job',
rating: 4.8,
totalReviews: 142,
jobsCompleted: 28,
revenueThisMonth: 18750,
hoursWorked: 156
};
const todayJobs: TodayJob[] = [
{ id: 'J-1001', customer: 'Smith Residence', service: 'HVAC Repair', time: '09:00 AM', status: 'completed', value: 450, address: '123 Main St' },
{ id: 'J-1002', customer: 'Downtown Office', service: 'Plumbing Install', time: '11:30 AM', status: 'in-progress', value: 1200, address: '456 Oak Ave' },
{ id: 'J-1003', customer: 'Oak Street Apt', service: 'Electrical Inspection', time: '02:00 PM', status: 'upcoming', value: 200, address: '789 Pine Dr' },
{ id: 'J-1004', customer: 'Martinez Home', service: 'AC Maintenance', time: '04:30 PM', status: 'upcoming', value: 150, address: '321 Elm St' }
];
const recentReviews: Review[] = [
{ id: 'R-1', customer: 'Sarah Wilson', rating: 5, comment: 'Mike was professional and fixed our AC quickly!', date: '2024-02-11', jobId: 'J-998' },
{ id: 'R-2', customer: 'John Davis', rating: 5, comment: 'Excellent service, very knowledgeable.', date: '2024-02-10', jobId: 'J-995' },
{ id: 'R-3', customer: 'Emily Chen', rating: 4, comment: 'Good work, arrived on time.', date: '2024-02-09', jobId: 'J-992' },
{ id: 'R-4', customer: 'Robert Martinez', rating: 5, comment: 'Outstanding! Will request Mike again.', date: '2024-02-08', jobId: 'J-989' }
];
export default function TechnicianDashboard({ api }: any) {
const [tech] = useState<TechnicianData>(mockTechnician);
const [jobs] = useState<TodayJob[]>(todayJobs);
const [reviews] = useState<Review[]>(recentReviews);
const getStatusClass = (status: string) => {
const classes: Record<string, string> = {
'available': 'status-available',
'on-job': 'status-on-job',
'offline': 'status-offline',
'upcoming': 'status-upcoming',
'in-progress': 'status-in-progress',
'completed': 'status-completed'
};
return classes[status] || '';
};
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
<span key={i} className={i < rating ? 'star filled' : 'star'}></span>
));
};
return (
<div className="technician-dashboard">
<header className="tech-header">
<div className="tech-profile">
<div className="avatar">{tech.name.split(' ').map(n => n[0]).join('')}</div>
<div className="tech-info">
<h1>{tech.name}</h1>
<div className="tech-meta">
<span className={`status-badge ${getStatusClass(tech.status)}`}>
{tech.status.replace('-', ' ')}
</span>
<span className="rating">
{tech.rating} ({tech.totalReviews} reviews)
</span>
</div>
</div>
</div>
</header>
<div className="stats-grid">
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-value">{tech.jobsCompleted}</div>
<div className="stat-label">Jobs Completed (MTD)</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">💰</div>
<div className="stat-content">
<div className="stat-value">${tech.revenueThisMonth.toLocaleString()}</div>
<div className="stat-label">Revenue (MTD)</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-value">{tech.hoursWorked}h</div>
<div className="stat-label">Hours Worked (MTD)</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-content">
<div className="stat-value">{tech.rating}</div>
<div className="stat-label">Average Rating</div>
</div>
</div>
</div>
<div className="dashboard-content">
<div className="schedule-section">
<h2>Today's Schedule</h2>
<div className="jobs-timeline">
{jobs.map(job => (
<div key={job.id} className={`timeline-job ${getStatusClass(job.status)}`}>
<div className="job-time">{job.time}</div>
<div className="job-details">
<div className="job-header">
<span className="job-id">{job.id}</span>
<span className={`status-dot ${getStatusClass(job.status)}`}></span>
</div>
<div className="job-customer">{job.customer}</div>
<div className="job-service">{job.service}</div>
<div className="job-address">📍 {job.address}</div>
<div className="job-value">${job.value}</div>
</div>
</div>
))}
</div>
</div>
<div className="reviews-section">
<h2>Recent Reviews</h2>
<div className="reviews-list">
{reviews.map(review => (
<div key={review.id} className="review-card">
<div className="review-header">
<div className="review-customer">{review.customer}</div>
<div className="review-date">{new Date(review.date).toLocaleDateString()}</div>
</div>
<div className="review-rating">{renderStars(review.rating)}</div>
<div className="review-comment">{review.comment}</div>
<div className="review-job">Job #{review.jobId}</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Technician Dashboard - Housecall Pro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App api={null} />
</React.StrictMode>
);

View File

@ -0,0 +1,308 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 20px;
}
.technician-dashboard {
max-width: 1400px;
margin: 0 auto;
}
.tech-header {
margin-bottom: 32px;
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
}
.tech-profile {
display: flex;
align-items: center;
gap: 20px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 700;
color: white;
}
.tech-info h1 {
font-size: 28px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 8px;
}
.tech-meta {
display: flex;
gap: 16px;
align-items: center;
}
.status-badge {
padding: 6px 14px;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
text-transform: capitalize;
}
.status-available {
background: rgba(16, 185, 129, 0.15);
color: #34d399;
}
.status-on-job {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.status-offline {
background: rgba(100, 116, 139, 0.15);
color: #94a3b8;
}
.rating {
color: #fbbf24;
font-size: 14px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
display: flex;
gap: 16px;
align-items: center;
}
.stat-icon {
font-size: 32px;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(59, 130, 246, 0.1);
border-radius: 12px;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #f1f5f9;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #94a3b8;
}
.dashboard-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.schedule-section,
.reviews-section {
background: #1e293b;
padding: 24px;
border-radius: 12px;
border: 1px solid #334155;
}
.schedule-section h2,
.reviews-section h2 {
font-size: 20px;
color: #f1f5f9;
margin-bottom: 20px;
}
.jobs-timeline {
display: flex;
flex-direction: column;
gap: 16px;
}
.timeline-job {
display: flex;
gap: 16px;
padding: 16px;
background: #0f172a;
border-radius: 8px;
border-left: 4px solid #3b82f6;
}
.timeline-job.status-completed {
border-left-color: #10b981;
opacity: 0.7;
}
.timeline-job.status-in-progress {
border-left-color: #f59e0b;
}
.timeline-job.status-upcoming {
border-left-color: #8b5cf6;
}
.job-time {
font-weight: 600;
color: #94a3b8;
min-width: 80px;
font-size: 14px;
}
.job-details {
flex: 1;
}
.job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.job-id {
font-size: 12px;
font-weight: 600;
color: #3b82f6;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.status-completed {
background: #10b981;
}
.status-dot.status-in-progress {
background: #f59e0b;
}
.status-dot.status-upcoming {
background: #8b5cf6;
}
.job-customer {
font-size: 16px;
font-weight: 600;
color: #f1f5f9;
margin-bottom: 4px;
}
.job-service {
font-size: 14px;
color: #94a3b8;
margin-bottom: 8px;
}
.job-address {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.job-value {
font-weight: 600;
color: #10b981;
font-size: 15px;
}
.reviews-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.review-card {
padding: 16px;
background: #0f172a;
border-radius: 8px;
border: 1px solid #334155;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.review-customer {
font-weight: 600;
color: #f1f5f9;
font-size: 15px;
}
.review-date {
font-size: 12px;
color: #64748b;
}
.review-rating {
margin-bottom: 10px;
}
.star {
color: #334155;
font-size: 18px;
}
.star.filled {
color: #fbbf24;
}
.review-comment {
color: #94a3b8;
font-size: 14px;
line-height: 1.6;
margin-bottom: 8px;
}
.review-job {
font-size: 12px;
color: #3b82f6;
}
@media (max-width: 1024px) {
.dashboard-content {
grid-template-columns: 1fr;
}
}