jobs
This commit is contained in:
parent
595fbcff0a
commit
4bb3c94784
115
app/admin/dashboard/page.tsx
Normal file
115
app/admin/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AdminLogo from '../../components/AdminLogo';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if admin is logged in
|
||||
const adminLoggedIn = sessionStorage.getItem('adminLoggedIn');
|
||||
if (adminLoggedIn !== 'true') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem('adminLoggedIn');
|
||||
router.push('/admin');
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminActions = [
|
||||
{
|
||||
id: 'add-job',
|
||||
title: 'Add New Job',
|
||||
description: 'Create a new service job for a vehicle',
|
||||
href: '/admin/jobs/add',
|
||||
},
|
||||
{
|
||||
id: 'view-jobs',
|
||||
title: 'View All Jobs',
|
||||
description: 'Browse and search all service jobs',
|
||||
href: '/admin/jobs',
|
||||
},
|
||||
// Add more actions here in the future
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminLogo />
|
||||
<div className="relative min-h-screen bg-black tech-grid scanlines">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-4 py-12">
|
||||
<div className="relative rounded-lg border-2 border-white/30 bg-black/80 backdrop-blur-sm p-8 neon-border-glow">
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h1 className="text-3xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-lg border border-white/30 bg-white/5 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-white/10 hover:border-white/50 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="mb-4 text-xl font-bold text-white uppercase tracking-wider">
|
||||
Admin Actions
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{adminActions.map((action) => (
|
||||
<Link
|
||||
key={action.id}
|
||||
href={action.href}
|
||||
className="group relative rounded-lg border-2 border-white/20 bg-black/50 p-6 transition-all hover:border-white/40 hover:bg-white/5 hover:shadow-[0_0_15px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white group-hover:animate-pulse"></div>
|
||||
<h3 className="text-lg font-bold text-white uppercase tracking-wider">
|
||||
{action.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-white/60 font-mono">
|
||||
{action.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-white/60 hover:text-white transition-colors font-mono uppercase tracking-wider"
|
||||
>
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
663
app/admin/jobs/add/page.tsx
Normal file
663
app/admin/jobs/add/page.tsx
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AdminLogo from '../../../components/AdminLogo';
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
make: string;
|
||||
model: string;
|
||||
capacity: number;
|
||||
yom: number;
|
||||
owner: number | null;
|
||||
}
|
||||
|
||||
export default function AddJobPage() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Form state
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>('');
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState<string>('');
|
||||
const [metadata, setMetadata] = useState('');
|
||||
|
||||
// Search state
|
||||
const [customerSearch, setCustomerSearch] = useState('');
|
||||
const [vehicleSearch, setVehicleSearch] = useState('');
|
||||
const [showCustomerResults, setShowCustomerResults] = useState(false);
|
||||
const [showVehicleResults, setShowVehicleResults] = useState(false);
|
||||
|
||||
// Inline creation state
|
||||
const [showAddCustomer, setShowAddCustomer] = useState(false);
|
||||
const [showAddVehicle, setShowAddVehicle] = useState(false);
|
||||
const [newCustomerName, setNewCustomerName] = useState('');
|
||||
const [newCustomerAddress, setNewCustomerAddress] = useState('');
|
||||
const [newVehicleId, setNewVehicleId] = useState('');
|
||||
const [newVehicleMake, setNewVehicleMake] = useState('');
|
||||
const [newVehicleModel, setNewVehicleModel] = useState('');
|
||||
const [newVehicleCapacity, setNewVehicleCapacity] = useState('');
|
||||
const [newVehicleYom, setNewVehicleYom] = useState('');
|
||||
|
||||
// Mock data - replace with API calls
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const adminLoggedIn = sessionStorage.getItem('adminLoggedIn');
|
||||
if (adminLoggedIn !== 'true') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
loadCustomers();
|
||||
loadVehicles();
|
||||
}, [router]);
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/customers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCustomers(data);
|
||||
} else {
|
||||
console.error('Failed to load customers');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadVehicles = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/vehicles');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setVehicles(data);
|
||||
} else {
|
||||
console.error('Failed to load vehicles');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading vehicles:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCustomer = async () => {
|
||||
if (!newCustomerName.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/customers', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newCustomerName,
|
||||
address: newCustomerAddress,
|
||||
tier: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const newCustomer = result.customer;
|
||||
setCustomers([...customers, newCustomer]);
|
||||
setSelectedCustomerId(newCustomer.id.toString());
|
||||
setNewCustomerName('');
|
||||
setNewCustomerAddress('');
|
||||
setShowAddCustomer(false);
|
||||
setCustomerSearch('');
|
||||
setShowCustomerResults(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create customer: ${error.error || error.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
alert('Failed to create customer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddVehicle = async () => {
|
||||
if (!newVehicleId.trim() || !newVehicleMake.trim() || !newVehicleModel.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/vehicles', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: newVehicleId.toUpperCase(),
|
||||
make: newVehicleMake,
|
||||
model: newVehicleModel,
|
||||
capacity: parseInt(newVehicleCapacity) || 200,
|
||||
yom: parseInt(newVehicleYom) || new Date().getFullYear(),
|
||||
owner: selectedCustomerId ? parseInt(selectedCustomerId) : null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const newVehicle = result.vehicle;
|
||||
setVehicles([...vehicles, newVehicle]);
|
||||
setSelectedVehicleId(newVehicle.id);
|
||||
setNewVehicleId('');
|
||||
setNewVehicleMake('');
|
||||
setNewVehicleModel('');
|
||||
setNewVehicleCapacity('');
|
||||
setNewVehicleYom('');
|
||||
setShowAddVehicle(false);
|
||||
setVehicleSearch('');
|
||||
setShowVehicleResults(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create vehicle: ${error.error || error.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating vehicle:', error);
|
||||
alert('Failed to create vehicle');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedCustomerId) {
|
||||
alert('Please select a customer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedVehicleId) {
|
||||
alert('Please select a vehicle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/jobs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
vehicleId: selectedVehicleId,
|
||||
metadata: metadata || null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert('Job created successfully!');
|
||||
router.push('/admin/dashboard');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to create job: ${error.error || error.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating job:', error);
|
||||
alert('Failed to create job');
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCustomer = customers.find(c => c.id.toString() === selectedCustomerId);
|
||||
const selectedVehicle = vehicles.find(v => v.id === selectedVehicleId);
|
||||
|
||||
const filteredCustomers = customers.filter(customer =>
|
||||
customer.name.toLowerCase().includes(customerSearch.toLowerCase()) ||
|
||||
(customer.address && customer.address.toLowerCase().includes(customerSearch.toLowerCase()))
|
||||
);
|
||||
|
||||
const filteredVehicles = vehicles
|
||||
.filter(v => !selectedCustomerId || v.owner === parseInt(selectedCustomerId))
|
||||
.filter(vehicle =>
|
||||
vehicle.id.toLowerCase().includes(vehicleSearch.toLowerCase()) ||
|
||||
vehicle.make.toLowerCase().includes(vehicleSearch.toLowerCase()) ||
|
||||
vehicle.model.toLowerCase().includes(vehicleSearch.toLowerCase())
|
||||
);
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.customer-search-container') && !target.closest('.vehicle-search-container')) {
|
||||
setShowCustomerResults(false);
|
||||
setShowVehicleResults(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminLogo />
|
||||
<div className="relative min-h-screen bg-black tech-grid scanlines">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-4xl px-4 py-12">
|
||||
<div className="relative rounded-lg border-2 border-white/30 bg-black/80 backdrop-blur-sm p-8 neon-border-glow">
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h1 className="text-3xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Add New Job
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="text-sm font-medium text-white/60 hover:text-white transition-colors font-mono uppercase tracking-wider border border-white/20 px-3 py-1 rounded hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Customer Selection */}
|
||||
<div className="relative customer-search-container">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Customer <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCustomerName(customerSearch);
|
||||
setShowAddCustomer(true);
|
||||
}}
|
||||
className="text-xs font-mono uppercase tracking-wider border border-cyan-500/50 bg-cyan-500/10 px-2 py-1 rounded text-cyan-400 transition-all hover:border-cyan-500 hover:bg-cyan-500/20 hover:text-cyan-300 hover:shadow-[0_0_10px_rgba(0,191,255,0.4)]"
|
||||
>
|
||||
+ Add New
|
||||
</button>
|
||||
</div>
|
||||
{selectedCustomer ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg border-2 border-white/30 bg-black/50 px-4 py-2">
|
||||
<span className="text-white font-mono">
|
||||
{selectedCustomer.name} {selectedCustomer.address ? `- ${selectedCustomer.address}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCustomerId('');
|
||||
setSelectedVehicleId('');
|
||||
setCustomerSearch('');
|
||||
}}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={customerSearch}
|
||||
onChange={(e) => {
|
||||
setCustomerSearch(e.target.value);
|
||||
setShowCustomerResults(true);
|
||||
}}
|
||||
onFocus={() => setShowCustomerResults(true)}
|
||||
placeholder="Search customer by name or address..."
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white placeholder-white/40 focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
{showCustomerResults && customerSearch && filteredCustomers.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border-2 border-white/20 bg-black/95 backdrop-blur-sm">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCustomerId(customer.id.toString());
|
||||
setSelectedVehicleId('');
|
||||
setCustomerSearch('');
|
||||
setShowCustomerResults(false);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-white hover:bg-white/10 font-mono transition-colors border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div className="font-semibold">{customer.name}</div>
|
||||
{customer.address && (
|
||||
<div className="text-sm text-white/60">{customer.address}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showCustomerResults && customerSearch && filteredCustomers.length === 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border-2 border-white/20 bg-black/95 backdrop-blur-sm px-4 py-3 text-white/60 font-mono text-sm">
|
||||
No customers found
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicle Selection */}
|
||||
<div className="relative vehicle-search-container">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Vehicle <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewVehicleId(vehicleSearch);
|
||||
setShowAddVehicle(true);
|
||||
}}
|
||||
className="text-xs font-mono uppercase tracking-wider border border-cyan-500/50 bg-cyan-500/10 px-2 py-1 rounded text-cyan-400 transition-all hover:border-cyan-500 hover:bg-cyan-500/20 hover:text-cyan-300 hover:shadow-[0_0_10px_rgba(0,191,255,0.4)]"
|
||||
>
|
||||
+ Add New
|
||||
</button>
|
||||
</div>
|
||||
{selectedVehicle ? (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg border-2 border-white/30 bg-black/50 px-4 py-2">
|
||||
<span className="text-white font-mono">
|
||||
{selectedVehicle.id} - {selectedVehicle.make} {selectedVehicle.model}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedVehicleId('');
|
||||
setVehicleSearch('');
|
||||
}}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={vehicleSearch}
|
||||
onChange={(e) => {
|
||||
setVehicleSearch(e.target.value);
|
||||
setShowVehicleResults(true);
|
||||
}}
|
||||
onFocus={() => setShowVehicleResults(true)}
|
||||
placeholder={selectedCustomerId ? "Search vehicles for selected customer..." : "Search vehicle by number, make, or model..."}
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white placeholder-white/40 focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
disabled={!selectedCustomerId}
|
||||
/>
|
||||
{!selectedCustomerId && (
|
||||
<p className="mt-1 text-xs text-white/60 font-mono">
|
||||
Please select a customer first
|
||||
</p>
|
||||
)}
|
||||
{showVehicleResults && vehicleSearch && filteredVehicles.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border-2 border-white/20 bg-black/95 backdrop-blur-sm">
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<button
|
||||
key={vehicle.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedVehicleId(vehicle.id);
|
||||
setVehicleSearch('');
|
||||
setShowVehicleResults(false);
|
||||
}}
|
||||
className="w-full px-4 py-3 text-left text-white hover:bg-white/10 font-mono transition-colors border-b border-white/10 last:border-b-0"
|
||||
>
|
||||
<div className="font-semibold">{vehicle.id}</div>
|
||||
<div className="text-sm text-white/60">
|
||||
{vehicle.make} {vehicle.model} {vehicle.capacity ? `(${vehicle.capacity}cc)` : ''}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showVehicleResults && vehicleSearch && filteredVehicles.length === 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border-2 border-white/20 bg-black/95 backdrop-blur-sm px-4 py-3 text-white/60 font-mono text-sm">
|
||||
No vehicles found
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Metadata / Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={metadata}
|
||||
onChange={(e) => setMetadata(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Additional notes or metadata..."
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white placeholder-white/40 focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-4">
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-black/50 px-6 py-3 text-center font-medium uppercase tracking-wider text-white transition-all hover:bg-white/10 hover:border-white/50 hover:shadow-[0_0_15px_rgba(255,255,255,0.3)]"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-white/5 px-6 py-3 font-bold uppercase tracking-wider text-white transition-all hover:bg-white/10 hover:border-white/50 hover:text-gray-200 hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
>
|
||||
Create Job
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Customer Modal */}
|
||||
{showAddCustomer && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4">
|
||||
<div className="relative w-full max-w-md rounded-lg border-2 border-white/30 bg-black/95 p-6 neon-border-glow">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider">
|
||||
Add New Customer
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddCustomer(false);
|
||||
setNewCustomerName('');
|
||||
setNewCustomerAddress('');
|
||||
}}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCustomerName}
|
||||
onChange={(e) => setNewCustomerName(e.target.value)}
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCustomerAddress}
|
||||
onChange={(e) => setNewCustomerAddress(e.target.value)}
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddCustomer(false);
|
||||
setNewCustomerName('');
|
||||
setNewCustomerAddress('');
|
||||
}}
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-black/50 px-4 py-2 text-white transition-all hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCustomer}
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-white/5 px-4 py-2 font-bold text-white transition-all hover:bg-white/10"
|
||||
>
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Vehicle Modal */}
|
||||
{showAddVehicle && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4">
|
||||
<div className="relative w-full max-w-md rounded-lg border-2 border-white/30 bg-black/95 p-6 neon-border-glow">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white uppercase tracking-wider">
|
||||
Add New Vehicle
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddVehicle(false);
|
||||
setNewVehicleId('');
|
||||
setNewVehicleMake('');
|
||||
setNewVehicleModel('');
|
||||
setNewVehicleCapacity('');
|
||||
setNewVehicleYom('');
|
||||
}}
|
||||
className="text-white/60 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Vehicle Number <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newVehicleId}
|
||||
onChange={(e) => setNewVehicleId(e.target.value.toUpperCase())}
|
||||
placeholder="ABC-1234"
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Make <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newVehicleMake}
|
||||
onChange={(e) => setNewVehicleMake(e.target.value)}
|
||||
placeholder="Honda"
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Model <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newVehicleModel}
|
||||
onChange={(e) => setNewVehicleModel(e.target.value)}
|
||||
placeholder="Civic"
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Capacity (cc)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newVehicleCapacity}
|
||||
onChange={(e) => setNewVehicleCapacity(e.target.value)}
|
||||
placeholder="200"
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white uppercase tracking-wider">
|
||||
Year of Manufacture
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newVehicleYom}
|
||||
onChange={(e) => setNewVehicleYom(e.target.value)}
|
||||
placeholder={new Date().getFullYear().toString()}
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedCustomerId && (
|
||||
<div className="rounded-lg border border-white/20 bg-white/5 p-3">
|
||||
<p className="text-xs text-white/60 font-mono">
|
||||
Vehicle will be linked to selected customer
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowAddVehicle(false);
|
||||
setNewVehicleId('');
|
||||
setNewVehicleMake('');
|
||||
setNewVehicleModel('');
|
||||
setNewVehicleCapacity('');
|
||||
setNewVehicleYom('');
|
||||
}}
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-black/50 px-4 py-2 text-white transition-all hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddVehicle}
|
||||
className="flex-1 rounded-lg border-2 border-white/30 bg-white/5 px-4 py-2 font-bold text-white transition-all hover:bg-white/10"
|
||||
>
|
||||
Add Vehicle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
540
app/admin/jobs/page.tsx
Normal file
540
app/admin/jobs/page.tsx
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AdminLogo from '../../components/AdminLogo';
|
||||
|
||||
interface JobAction {
|
||||
id: number;
|
||||
action_type: string;
|
||||
part_type: string | null;
|
||||
part_id: string | null;
|
||||
custom_price: number;
|
||||
labour_fee: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
make: string;
|
||||
model: string;
|
||||
capacity: number | null;
|
||||
yom: number | null;
|
||||
owner: number | null;
|
||||
}
|
||||
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface ServiceRecord {
|
||||
id: number;
|
||||
created_at: string;
|
||||
vehicle: string;
|
||||
metadata: string | null;
|
||||
status: number;
|
||||
Vehicles?: Vehicle;
|
||||
}
|
||||
|
||||
export default function JobsListPage() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const router = useRouter();
|
||||
const [jobs, setJobs] = useState<ServiceRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [selectedJob, setSelectedJob] = useState<ServiceRecord | null>(null);
|
||||
const [jobActions, setJobActions] = useState<JobAction[]>([]);
|
||||
const [loadingActions, setLoadingActions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const adminLoggedIn = sessionStorage.getItem('adminLoggedIn');
|
||||
if (adminLoggedIn !== 'true') {
|
||||
router.push('/admin');
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
loadJobs();
|
||||
loadCustomers();
|
||||
}, [router]);
|
||||
|
||||
// Close modal when clicking outside or pressing Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && selectedJob) {
|
||||
setSelectedJob(null);
|
||||
setJobActions([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedJob) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [selectedJob]);
|
||||
|
||||
const loadJobs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/admin/jobs');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setJobs(data);
|
||||
} else {
|
||||
console.error('Failed to load jobs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading jobs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/customers');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCustomers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading customers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getCustomerName = (ownerId: number | null | undefined) => {
|
||||
if (!ownerId) return 'Unknown';
|
||||
const customer = customers.find(c => c.id === ownerId);
|
||||
return customer ? customer.name : 'Unknown';
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: number) => {
|
||||
switch (status) {
|
||||
case 0: return 'Pending';
|
||||
case 1: return 'Active';
|
||||
case 2: return 'Waiting for Customer Response';
|
||||
case 3: return 'Waiting for Parts';
|
||||
case 10: return 'Completed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 0: return 'text-white/60';
|
||||
case 1: return 'text-cyan-400';
|
||||
case 2: return 'text-yellow-400';
|
||||
case 3: return 'text-orange-400';
|
||||
case 10: return 'text-green-400';
|
||||
default: return 'text-white/60';
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDetails = async (job: ServiceRecord) => {
|
||||
setSelectedJob(job);
|
||||
setLoadingActions(true);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/jobs/${job.id}/actions`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setJobActions(data);
|
||||
} else {
|
||||
console.error('Failed to load job actions');
|
||||
setJobActions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading job actions:', error);
|
||||
setJobActions([]);
|
||||
} finally {
|
||||
setLoadingActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredJobs = jobs.filter(job => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
const vehicleId = job.Vehicles?.id?.toLowerCase() || '';
|
||||
const vehicleMake = job.Vehicles?.make?.toLowerCase() || '';
|
||||
const vehicleModel = job.Vehicles?.model?.toLowerCase() || '';
|
||||
const metadata = job.metadata?.toLowerCase() || '';
|
||||
const customerName = getCustomerName(job.Vehicles?.owner).toLowerCase();
|
||||
const statusLabel = getStatusLabel(job.status).toLowerCase();
|
||||
|
||||
return (
|
||||
vehicleId.includes(query) ||
|
||||
vehicleMake.includes(query) ||
|
||||
vehicleModel.includes(query) ||
|
||||
metadata.includes(query) ||
|
||||
customerName.includes(query) ||
|
||||
statusLabel.includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminLogo />
|
||||
<div className="relative min-h-screen bg-black tech-grid scanlines">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="relative rounded-lg border-2 border-white/30 bg-black/80 backdrop-blur-sm p-8 neon-border-glow">
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h1 className="text-3xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
All Jobs
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/dashboard"
|
||||
className="text-sm font-medium text-white/60 hover:text-white transition-colors font-mono uppercase tracking-wider border border-white/20 px-3 py-1 rounded hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by vehicle number, make, model, customer, status, or metadata..."
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 font-mono text-white placeholder-white/40 focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Jobs List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse mx-auto mb-4"></div>
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">Loading Jobs...</p>
|
||||
</div>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">
|
||||
{searchQuery ? 'No jobs found matching your search' : 'No jobs found'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredJobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="rounded-lg border-2 border-white/20 bg-black/50 p-6 transition-all hover:border-white/40 hover:bg-white/5 hover:shadow-[0_0_15px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Vehicle
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
{job.Vehicles?.id || 'N/A'}
|
||||
</div>
|
||||
<div className="text-sm text-white/70 font-mono">
|
||||
{job.Vehicles?.make || 'N/A'} {job.Vehicles?.model || ''}
|
||||
{job.Vehicles?.capacity && ` (${job.Vehicles.capacity}cc)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Customer
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{getCustomerName(job.Vehicles?.owner)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Status
|
||||
</div>
|
||||
<div className={`font-mono font-semibold ${getStatusColor(job.status)}`}>
|
||||
{getStatusLabel(job.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(job.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{job.metadata && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Notes
|
||||
</div>
|
||||
<div className="text-white/80 font-mono text-sm">
|
||||
{job.metadata}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div className="text-xs text-white/40 font-mono">
|
||||
Service Record ID: {job.id}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowDetails(job)}
|
||||
className="rounded-lg border border-cyan-500/50 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-400 transition-all hover:bg-cyan-500/20 hover:border-cyan-500 hover:shadow-[0_0_15px_rgba(0,191,255,0.4)] font-mono uppercase tracking-wider"
|
||||
>
|
||||
Show Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredJobs.length > 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-white/60 font-mono text-sm uppercase tracking-wider">
|
||||
Showing {filteredJobs.length} of {jobs.length} jobs
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job Details Modal */}
|
||||
{selectedJob && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 overflow-y-auto"
|
||||
onClick={() => {
|
||||
setSelectedJob(null);
|
||||
setJobActions([]);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl rounded-lg border-2 border-white/30 bg-black/95 backdrop-blur-sm p-8 neon-border-glow my-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h2 className="text-2xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Job Details
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedJob(null);
|
||||
setJobActions([]);
|
||||
}}
|
||||
className="text-white/60 hover:text-white text-2xl transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service Record Details */}
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Service Record ID
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
#{selectedJob.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Status
|
||||
</div>
|
||||
<div className={`font-mono font-semibold ${getStatusColor(selectedJob.status)}`}>
|
||||
{getStatusLabel(selectedJob.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Vehicle Number
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
{selectedJob.Vehicles?.id || 'N/A'}
|
||||
</div>
|
||||
<div className="text-sm text-white/70 font-mono">
|
||||
{selectedJob.Vehicles?.make || 'N/A'} {selectedJob.Vehicles?.model || ''}
|
||||
{selectedJob.Vehicles?.capacity && ` (${selectedJob.Vehicles.capacity}cc)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Customer
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{getCustomerName(selectedJob.Vehicles?.owner)}
|
||||
</div>
|
||||
{selectedJob.Vehicles?.owner && (
|
||||
<div className="text-sm text-white/60 font-mono">
|
||||
Customer ID: {selectedJob.Vehicles.owner}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(selectedJob.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedJob.metadata && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Metadata / Notes
|
||||
</div>
|
||||
<div className="text-white/80 font-mono text-sm bg-black/50 rounded-lg p-4 border border-white/10">
|
||||
{selectedJob.metadata}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job Actions List */}
|
||||
<div className="border-t border-white/20 pt-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-cyan-500"></div>
|
||||
<h3 className="text-xl font-bold text-white uppercase tracking-wider">
|
||||
Job Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-white/60 font-mono">
|
||||
{jobActions.length} action{jobActions.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingActions ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse mx-auto mb-2"></div>
|
||||
<p className="text-white/60 font-mono text-sm uppercase tracking-wider">
|
||||
Loading Actions...
|
||||
</p>
|
||||
</div>
|
||||
) : jobActions.length === 0 ? (
|
||||
<div className="text-center py-8 rounded-lg border border-white/10 bg-black/50">
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">
|
||||
No job actions found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{jobActions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="rounded-lg border border-white/20 bg-black/50 p-4 hover:border-white/40 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Action Type
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
{action.action_type}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(action.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.part_type && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Part Type
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{action.part_type}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.part_id && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Part ID
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{action.part_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.labour_fee > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Labour Fee
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
₹{action.labour_fee}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.custom_price > 0 && action.custom_price !== -1 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Custom Price
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
₹{action.custom_price}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-white/10">
|
||||
<div className="text-xs text-white/40 font-mono">
|
||||
Action ID: {action.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
97
app/admin/page.tsx
Normal file
97
app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AdminLogo from '../components/AdminLogo';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password === '111222') {
|
||||
// Store admin session
|
||||
sessionStorage.setItem('adminLoggedIn', 'true');
|
||||
router.push('/admin/dashboard');
|
||||
} else {
|
||||
setError('Invalid password');
|
||||
setPassword('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminLogo />
|
||||
<div className="relative min-h-screen bg-black tech-grid scanlines">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-4 py-12">
|
||||
<div className="relative w-full rounded-lg border-2 border-white/30 bg-black/80 backdrop-blur-sm p-8 neon-border-glow">
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h1 className="text-3xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Admin Login
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mb-6 text-sm text-white/60 font-mono">
|
||||
ENTER ADMIN PASSWORD TO CONTINUE
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-2 block text-sm font-medium text-white uppercase tracking-wider"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
className="w-full rounded-lg border-2 border-white/20 bg-black/50 px-4 py-3 text-lg font-mono text-white placeholder-white/40 focus:border-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 neon-border transition-all"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-500 font-mono">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-lg border-2 border-white/30 bg-white/5 px-6 py-3 text-lg font-bold uppercase tracking-wider text-white transition-all hover:bg-white/10 hover:border-white/50 hover:text-gray-200 hover:shadow-[0_0_20px_rgba(255,255,255,0.3)] focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-white/60 hover:text-white transition-colors font-mono uppercase tracking-wider"
|
||||
>
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
66
app/api/admin/customers/route.ts
Normal file
66
app/api/admin/customers/route.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// POST /api/admin/customers - Create a new customer
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, address, tier } = body;
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('Customers')
|
||||
.insert({
|
||||
name: name,
|
||||
address: address || '',
|
||||
tier: tier || 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create customer', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Customer created successfully', customer: data },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create customer' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/customers - Get all customers
|
||||
export async function GET() {
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('Customers')
|
||||
.select('*')
|
||||
.order('registered_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch customers', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch customers' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
36
app/api/admin/jobs/[id]/actions/route.ts
Normal file
36
app/api/admin/jobs/[id]/actions/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// GET /api/admin/jobs/[id]/actions - Get all job actions for a service record
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const serviceRecordId = id;
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('JobActions')
|
||||
.select('*')
|
||||
.eq('service_record', serviceRecordId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch job actions', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching job actions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch job actions' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
app/api/admin/jobs/route.ts
Normal file
70
app/api/admin/jobs/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// POST /api/admin/jobs - Create a new job (ServiceRecord)
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { vehicleId, metadata } = body;
|
||||
|
||||
// Create the ServiceRecord (status defaults to 0 = pending)
|
||||
const { data: serviceRecord, error: recordError } = await supabaseAdmin
|
||||
.from('ServiceRecords')
|
||||
.insert({
|
||||
vehicle: vehicleId,
|
||||
metadata: metadata || null,
|
||||
status: 0, // Pending
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (recordError) {
|
||||
console.error('Supabase error creating ServiceRecord:', recordError);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create service record', details: recordError.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Job created successfully', serviceRecord },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating job:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create job' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/jobs - Get all jobs
|
||||
export async function GET() {
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('ServiceRecords')
|
||||
.select(`
|
||||
*,
|
||||
Vehicles (*)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch jobs', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching jobs:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch jobs' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
69
app/api/admin/vehicles/route.ts
Normal file
69
app/api/admin/vehicles/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// POST /api/admin/vehicles - Create a new vehicle
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, make, model, capacity, yom, owner } = body;
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('Vehicles')
|
||||
.insert({
|
||||
id: typeof id === 'string' ? id.toUpperCase() : id,
|
||||
make: make || 'tvs',
|
||||
model: model || 'rtr 200',
|
||||
capacity: capacity || 200,
|
||||
yom: yom || new Date().getFullYear(),
|
||||
owner: owner || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create vehicle', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: 'Vehicle created successfully', vehicle: data },
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating vehicle:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create vehicle' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/admin/vehicles - Get all vehicles
|
||||
export async function GET() {
|
||||
try {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('Vehicles')
|
||||
.select('*')
|
||||
.order('registered_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch vehicles', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching vehicles:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch vehicles' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
35
app/api/vehicle/[vehicleId]/actions/[recordId]/route.ts
Normal file
35
app/api/vehicle/[vehicleId]/actions/[recordId]/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// GET /api/vehicle/[vehicleId]/actions/[recordId] - Get all job actions for a service record
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ vehicleId: string; recordId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { recordId } = await params;
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('JobActions')
|
||||
.select('*')
|
||||
.eq('service_record', parseInt(recordId))
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch job actions', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching job actions:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch job actions' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
39
app/api/vehicle/[vehicleId]/records/route.ts
Normal file
39
app/api/vehicle/[vehicleId]/records/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { supabaseAdmin } from '@/lib/supabase';
|
||||
|
||||
// GET /api/vehicle/[vehicleId]/records - Get all service records for a vehicle
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ vehicleId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { vehicleId } = await params;
|
||||
const decodedVehicleId = decodeURIComponent(vehicleId);
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('ServiceRecords')
|
||||
.select(`
|
||||
*,
|
||||
Vehicles (*)
|
||||
`)
|
||||
.eq('vehicle', decodedVehicleId.toUpperCase())
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch service records', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching service records:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch service records' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
28
app/components/AdminLogo.tsx
Normal file
28
app/components/AdminLogo.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminLogo() {
|
||||
return (
|
||||
<div className="relative w-full border-b border-white/20 bg-black/95 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 tech-grid opacity-20"></div>
|
||||
<div className="relative mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center group">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src="/100kmphlogo.png"
|
||||
alt="100kmph Logo"
|
||||
width={100}
|
||||
height={100}
|
||||
className="rounded"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 rounded bg-white/10 blur-md group-hover:bg-white/20 transition-all"></div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,11 +1,18 @@
|
|||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Header() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
// Hide header on admin pages
|
||||
if (pathname?.startsWith('/admin')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="relative w-full border-b border-white/20 bg-black/95 backdrop-blur-sm">
|
||||
|
|
@ -17,8 +24,8 @@ export default function Header() {
|
|||
<Image
|
||||
src="/100kmphlogo.png"
|
||||
alt="100kmph Logo"
|
||||
width={100}
|
||||
height={100}
|
||||
width={120}
|
||||
height={120}
|
||||
className="rounded"
|
||||
priority
|
||||
/>
|
||||
|
|
@ -67,4 +74,3 @@ export default function Header() {
|
|||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,11 +12,8 @@ export default function Home() {
|
|||
const handleGuestLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (vehicleNumber.trim()) {
|
||||
// TODO: Handle guest login logic
|
||||
// For now, just show an alert or navigate to status page
|
||||
console.log('Checking status for vehicle:', vehicleNumber);
|
||||
// You can navigate to a status page here
|
||||
// router.push(`/status/${vehicleNumber}`);
|
||||
// Navigate to vehicle status page
|
||||
router.push(`/vehicle/${encodeURIComponent(vehicleNumber.trim().toUpperCase())}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
463
app/vehicle/[vehicleId]/page.tsx
Normal file
463
app/vehicle/[vehicleId]/page.tsx
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface JobAction {
|
||||
id: number;
|
||||
action_type: string;
|
||||
part_type: string | null;
|
||||
part_id: string | null;
|
||||
custom_price: number;
|
||||
labour_fee: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
make: string;
|
||||
model: string;
|
||||
capacity: number | null;
|
||||
yom: number | null;
|
||||
owner: number | null;
|
||||
}
|
||||
|
||||
interface ServiceRecord {
|
||||
id: number;
|
||||
created_at: string;
|
||||
vehicle: string;
|
||||
metadata: string | null;
|
||||
status: number;
|
||||
Vehicles?: Vehicle;
|
||||
}
|
||||
|
||||
export default function VehicleStatusPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const vehicleId = params?.vehicleId as string;
|
||||
const [serviceRecords, setServiceRecords] = useState<ServiceRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRecord, setSelectedRecord] = useState<ServiceRecord | null>(null);
|
||||
const [jobActions, setJobActions] = useState<JobAction[]>([]);
|
||||
const [loadingActions, setLoadingActions] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (vehicleId) {
|
||||
loadServiceRecords();
|
||||
}
|
||||
}, [vehicleId]);
|
||||
|
||||
// Close modal when clicking outside or pressing Escape
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && selectedRecord) {
|
||||
setSelectedRecord(null);
|
||||
setJobActions([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedRecord) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [selectedRecord]);
|
||||
|
||||
const loadServiceRecords = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/vehicle/${encodeURIComponent(vehicleId)}/records`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setServiceRecords(data);
|
||||
} else {
|
||||
console.error('Failed to load service records');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading service records:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: number) => {
|
||||
switch (status) {
|
||||
case 0: return 'Pending';
|
||||
case 1: return 'Active';
|
||||
case 2: return 'Waiting for Customer Response';
|
||||
case 3: return 'Waiting for Parts';
|
||||
case 10: return 'Completed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 0: return 'text-white/60';
|
||||
case 1: return 'text-cyan-400';
|
||||
case 2: return 'text-yellow-400';
|
||||
case 3: return 'text-orange-400';
|
||||
case 10: return 'text-green-400';
|
||||
default: return 'text-white/60';
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowDetails = async (record: ServiceRecord) => {
|
||||
setSelectedRecord(record);
|
||||
setLoadingActions(true);
|
||||
try {
|
||||
const response = await fetch(`/api/vehicle/${encodeURIComponent(vehicleId)}/actions/${record.id}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setJobActions(data);
|
||||
} else {
|
||||
console.error('Failed to load job actions');
|
||||
setJobActions([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading job actions:', error);
|
||||
setJobActions([]);
|
||||
} finally {
|
||||
setLoadingActions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const vehicle = serviceRecords[0]?.Vehicles;
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen bg-black tech-grid scanlines">
|
||||
{/* Animated background elements */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 h-96 w-96 rounded-full bg-white/5 blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="relative rounded-lg border-2 border-white/30 bg-black/80 backdrop-blur-sm p-8 neon-border-glow">
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h1 className="text-3xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Service Status
|
||||
</h1>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm font-medium text-white/60 hover:text-white transition-colors font-mono uppercase tracking-wider border border-white/20 px-3 py-1 rounded hover:border-white/40 hover:bg-white/10"
|
||||
>
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Vehicle Info */}
|
||||
{vehicle && (
|
||||
<div className="mb-8 rounded-lg border-2 border-white/20 bg-black/50 p-6">
|
||||
<div className="mb-4 text-center">
|
||||
<div className="mb-2 text-2xl font-bold text-white font-mono">
|
||||
{vehicle.id}
|
||||
</div>
|
||||
<div className="text-white/80 font-mono">
|
||||
{vehicle.make} {vehicle.model}
|
||||
{vehicle.capacity && ` (${vehicle.capacity}cc)`}
|
||||
{vehicle.yom && ` - ${vehicle.yom}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Records List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse mx-auto mb-4"></div>
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">Loading Service Records...</p>
|
||||
</div>
|
||||
) : serviceRecords.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">
|
||||
No service records found for vehicle {vehicleId}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{serviceRecords.map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="rounded-lg border-2 border-white/20 bg-black/50 p-6 transition-all hover:border-white/40 hover:bg-white/5 hover:shadow-[0_0_15px_rgba(255,255,255,0.2)]"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Service Record ID
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
#{record.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Status
|
||||
</div>
|
||||
<div className={`font-mono font-semibold ${getStatusColor(record.status)}`}>
|
||||
{getStatusLabel(record.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(record.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{record.metadata && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10">
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Notes
|
||||
</div>
|
||||
<div className="text-white/80 font-mono text-sm">
|
||||
{record.metadata}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div className="text-xs text-white/40 font-mono">
|
||||
Record ID: {record.id}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleShowDetails(record)}
|
||||
className="rounded-lg border border-cyan-500/50 bg-cyan-500/10 px-4 py-2 text-sm font-medium text-cyan-400 transition-all hover:bg-cyan-500/20 hover:border-cyan-500 hover:shadow-[0_0_15px_rgba(0,191,255,0.4)] font-mono uppercase tracking-wider"
|
||||
>
|
||||
Show Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && serviceRecords.length > 0 && (
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-white/60 font-mono text-sm uppercase tracking-wider">
|
||||
{serviceRecords.length} service record{serviceRecords.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{selectedRecord && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 overflow-y-auto"
|
||||
onClick={() => {
|
||||
setSelectedRecord(null);
|
||||
setJobActions([]);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-4xl rounded-lg border-2 border-white/30 bg-black/95 backdrop-blur-sm p-8 neon-border-glow my-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-white/5 to-transparent"></div>
|
||||
<div className="relative">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse"></div>
|
||||
<h2 className="text-2xl font-bold text-white uppercase tracking-wider neon-glow">
|
||||
Service Record Details
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedRecord(null);
|
||||
setJobActions([]);
|
||||
}}
|
||||
className="text-white/60 hover:text-white text-2xl transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service Record Details */}
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Service Record ID
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
#{selectedRecord.id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Status
|
||||
</div>
|
||||
<div className={`font-mono font-semibold ${getStatusColor(selectedRecord.status)}`}>
|
||||
{getStatusLabel(selectedRecord.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Vehicle Number
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
{selectedRecord.Vehicles?.id || 'N/A'}
|
||||
</div>
|
||||
<div className="text-sm text-white/70 font-mono">
|
||||
{selectedRecord.Vehicles?.make || 'N/A'} {selectedRecord.Vehicles?.model || ''}
|
||||
{selectedRecord.Vehicles?.capacity && ` (${selectedRecord.Vehicles.capacity}cc)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(selectedRecord.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedRecord.metadata && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Notes
|
||||
</div>
|
||||
<div className="text-white/80 font-mono text-sm bg-black/50 rounded-lg p-4 border border-white/10">
|
||||
{selectedRecord.metadata}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Job Actions List */}
|
||||
<div className="border-t border-white/20 pt-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-cyan-500"></div>
|
||||
<h3 className="text-xl font-bold text-white uppercase tracking-wider">
|
||||
Job Actions
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-sm text-white/60 font-mono">
|
||||
{jobActions.length} action{jobActions.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingActions ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="h-2 w-2 rounded-full bg-white animate-pulse mx-auto mb-2"></div>
|
||||
<p className="text-white/60 font-mono text-sm uppercase tracking-wider">
|
||||
Loading Actions...
|
||||
</p>
|
||||
</div>
|
||||
) : jobActions.length === 0 ? (
|
||||
<div className="text-center py-8 rounded-lg border border-white/10 bg-black/50">
|
||||
<p className="text-white/60 font-mono uppercase tracking-wider">
|
||||
No job actions found
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{jobActions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="rounded-lg border border-white/20 bg-black/50 p-4 hover:border-white/40 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Action Type
|
||||
</div>
|
||||
<div className="text-white font-mono font-semibold">
|
||||
{action.action_type}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Created At
|
||||
</div>
|
||||
<div className="text-white font-mono text-sm">
|
||||
{new Date(action.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action.part_type && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Part Type
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{action.part_type}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.part_id && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Part ID
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{action.part_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.labour_fee > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Labour Fee
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
₹{action.labour_fee}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{action.custom_price > 0 && action.custom_price !== -1 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-white/60 font-mono uppercase tracking-wider">
|
||||
Custom Price
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
₹{action.custom_price}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 pt-2 border-t border-white/10">
|
||||
<div className="text-xs text-white/40 font-mono">
|
||||
Action ID: {action.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
44
db_structure_sql
Normal file
44
db_structure_sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
-- WARNING: This schema is for context only and is not meant to be run.
|
||||
-- Table order and constraints may not be valid for execution.
|
||||
|
||||
CREATE TABLE public.Customers (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
registered_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
name character varying,
|
||||
address character varying DEFAULT ''::character varying,
|
||||
tier smallint DEFAULT '0'::smallint,
|
||||
CONSTRAINT Customers_pkey PRIMARY KEY (id)
|
||||
);
|
||||
CREATE TABLE public.JobActions (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
action_type character varying NOT NULL,
|
||||
part_type character varying,
|
||||
part_id character varying,
|
||||
custom_price bigint DEFAULT '-1'::bigint,
|
||||
labour_fee bigint DEFAULT '0'::bigint,
|
||||
service_record bigint,
|
||||
CONSTRAINT JobActions_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT JobActions_service_record_fkey FOREIGN KEY (service_record) REFERENCES public.ServiceRecords(id)
|
||||
);
|
||||
CREATE TABLE public.ServiceRecords (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
vehicle character varying,
|
||||
metadata character varying,
|
||||
status smallint DEFAULT '0'::smallint,
|
||||
CONSTRAINT ServiceRecords_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT ServiceRecords_vehicle_fkey FOREIGN KEY (vehicle) REFERENCES public.Vehicles(id)
|
||||
);
|
||||
CREATE TABLE public.Vehicles (
|
||||
id character varying NOT NULL DEFAULT 'SDR-5000'::character varying,
|
||||
registered_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
make character varying DEFAULT 'tvs'::character varying,
|
||||
model character varying DEFAULT 'rtr 200'::character varying,
|
||||
capacity integer DEFAULT 200,
|
||||
yom integer DEFAULT 2018,
|
||||
metadata character varying,
|
||||
owner bigint,
|
||||
CONSTRAINT Vehicles_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT Vehicles_owner_fkey FOREIGN KEY (owner) REFERENCES public.Customers(id)
|
||||
);
|
||||
21
lib/supabase.ts
Normal file
21
lib/supabase.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
|
||||
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_KEY!;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
// Client for public operations (uses anon key)
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
// Admin client for server-side operations (bypasses RLS)
|
||||
// Use service role key if available, otherwise fallback to anon key
|
||||
const adminKey = process.env.SUPABASE_SERVICE_ROLE_KEY || supabaseKey;
|
||||
export const supabaseAdmin = createClient(supabaseUrl, adminKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false
|
||||
}
|
||||
});
|
||||
118
package-lock.json
generated
118
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "100kmph",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.79.0",
|
||||
"next": "16.0.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
|
|
@ -1195,6 +1196,85 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.79.0.tgz",
|
||||
"integrity": "sha512-p2GKvdbF9d/6C+dtS6iNcSicPr6eUfkvovD60HWlWsD+oOjC483DzFWrzGjNpBwnswhfMRP8Qn3rYA0VWaOfjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.79.0.tgz",
|
||||
"integrity": "sha512-WaiU6b+Z+ZfJOjFhpMKdajt42weiFUrA6TVW5oGd6WfPGajFiKZJJIAvuK0g7KDKaYowtQrOo5+Ais+PcuZ1qA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.79.0.tgz",
|
||||
"integrity": "sha512-2i8EFm3/49ecjt6dk/TGVROBbtOmhryiC4NL3u0FBIrm2hqj+FvbELv1jjM6r+a6abnh+uzIV/bFsWHAa/k3/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.79.0.tgz",
|
||||
"integrity": "sha512-foaZujNBycAqLizUcuLyyFyDitfPnEMVO4CiKXNwaMCDVMoVX4QR6n4gpJLUC5BGzc20Mte6vSJLbk4MN90Prw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.79.0.tgz",
|
||||
"integrity": "sha512-PLSeKX1/BZhGWCT972w4TvVOCcw/xh4TsowtUBiZvPx4OdHT7dB1q0DXKwVUfKbWk5UUC+6XAq4ZU/ZCtdgn6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.79.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.79.0.tgz",
|
||||
"integrity": "sha512-x9ndEaBSwoRnFOOZGhh2CeV69Uz4B/EOSGCbKysDhTiYakiCAdDXaNuLPluviKU/Aot+F7BglXZDZ0YJ3GpGrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.79.0",
|
||||
"@supabase/functions-js": "2.79.0",
|
||||
"@supabase/postgrest-js": "2.79.0",
|
||||
"@supabase/realtime-js": "2.79.0",
|
||||
"@supabase/storage-js": "2.79.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
|
|
@ -1511,12 +1591,17 @@
|
|||
"version": "20.19.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
|
|
@ -1537,6 +1622,15 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
|
||||
|
|
@ -6296,7 +6390,6 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
@ -6490,6 +6583,27 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -9,18 +9,19 @@
|
|||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.79.0",
|
||||
"next": "16.0.1",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"next": "16.0.1"
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.1"
|
||||
"eslint-config-next": "16.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
0
supabase_config
Normal file
0
supabase_config
Normal file
Loading…
Reference in New Issue
Block a user