feat: initialize project with Next.js, TypeScript, and Tailwind CSS
- Added package.json with dependencies for Next.js, React, and Tailwind CSS. - Created postcss.config.js for Tailwind CSS and autoprefixer integration. - Added SVG icon for the application in public/icons/icon.svg. - Created manifest.json for PWA support with app details and icon. - Configured Tailwind CSS in tailwind.config.js with custom colors and animations. - Set up TypeScript configuration in tsconfig.json for strict type checking and module resolution.
This commit is contained in:
commit
b738bc7495
32 changed files with 9321 additions and 0 deletions
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules
|
||||
.next
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# PocketBase Configuration
|
||||
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
|
||||
|
||||
# This env var is embedded at build time (NEXT_PUBLIC_ prefix)
|
||||
# Update this if your PocketBase URL changes
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.env
|
||||
14
.vscode/settings.json
vendored
Normal file
14
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/.DS_Store": true,
|
||||
"**/Thumbs.db": true,
|
||||
".claude": true,
|
||||
".codex": true,
|
||||
".gemini": true,
|
||||
".venv": true,
|
||||
".superdoc": true
|
||||
}
|
||||
}
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Multi-stage build for Next.js static export
|
||||
# Stage 1: Build the app
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build argument for PocketBase URL
|
||||
ARG NEXT_PUBLIC_POCKETBASE_URL
|
||||
ENV NEXT_PUBLIC_POCKETBASE_URL=${NEXT_PUBLIC_POCKETBASE_URL}
|
||||
|
||||
# Build the static export
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Remove default nginx static assets
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built static files from builder
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
108
README.md
Normal file
108
README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 1Driver - Mobile Collections App
|
||||
|
||||
A standalone mobile-first PWA app for drivers to manage collections in the field.
|
||||
|
||||
## Features
|
||||
|
||||
- **Mobile-first design** - Optimized for phone use with touch-friendly UI
|
||||
- **PWA support** - Install as a standalone app on iOS and Android
|
||||
- **Location tracking** - Background location pings at configurable intervals
|
||||
- **Collection list** - View assigned collections grouped by supplier
|
||||
- **Map view** - See all collection stops on an interactive Leaflet map
|
||||
- **Offline support** - Queue actions when offline and sync when connected
|
||||
- **Problem reporting** - Report issues with photos and problem types
|
||||
|
||||
## Screens
|
||||
|
||||
1. **List View** - Shows all assigned collections for today/tomorrow, grouped by supplier
|
||||
2. **Map View** - Shows collection stops on a map with navigation integration
|
||||
3. **Settings** - Configure location tracking interval, accuracy, and display options
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 14 (App Router)
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- PocketBase SDK
|
||||
- Leaflet / React Leaflet
|
||||
- Lucide Icons
|
||||
|
||||
## PocketBase Integration
|
||||
|
||||
The app connects to your PocketBase instance at:
|
||||
```
|
||||
https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
|
||||
```
|
||||
|
||||
### Required Collections
|
||||
|
||||
1. **collections** - The main collection items (already exists)
|
||||
2. **collection_problems** - Problem reports
|
||||
```
|
||||
Fields:
|
||||
- collection (relation to collections)
|
||||
- problem_type (select: out_of_stock, partial_stock, quality_issue, wrong_item, other)
|
||||
- description (text)
|
||||
- photo (file)
|
||||
- reported_at (date)
|
||||
```
|
||||
3. **driver_locations** - Location tracking data
|
||||
```
|
||||
Fields:
|
||||
- driver (relation to users)
|
||||
- latitude (number)
|
||||
- longitude (number)
|
||||
- accuracy (number, optional)
|
||||
- altitude (number, optional)
|
||||
- speed (number, optional)
|
||||
- heading (number, optional)
|
||||
- timestamp (date)
|
||||
```
|
||||
4. **order_items** - Order items (already exists)
|
||||
5. **suppliers** - Supplier data with lat/lng (already exists)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
The app is configured for static export. Build output goes to the `dist/` folder.
|
||||
|
||||
For PWA deployment, serve the `dist/` folder with any static file server.
|
||||
|
||||
## iOS Installation
|
||||
|
||||
1. Open the app URL in Safari
|
||||
2. Tap the Share button
|
||||
3. Tap "Add to Home Screen"
|
||||
4. The app will install as a standalone PWA
|
||||
|
||||
## Android Installation
|
||||
|
||||
1. Open the app URL in Chrome
|
||||
2. Tap the menu (3 dots)
|
||||
3. Tap "Add to Home Screen" or "Install App"
|
||||
|
||||
## Location Permissions
|
||||
|
||||
The app requires location permissions for:
|
||||
- Showing your current position on the map
|
||||
- Tracking driver locations at set intervals
|
||||
- Calculating distances to collection stops
|
||||
|
||||
## Notes
|
||||
|
||||
- Location tracking runs in the background while the app is open
|
||||
- The app uses the PocketBase `users` collection for authentication
|
||||
- Collections are filtered to show only those assigned to the logged-in driver
|
||||
- Status updates (collected/problem) sync immediately when online
|
||||
128
app/globals.css
Normal file
128
app/globals.css
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 245, 245, 245;
|
||||
--primary: 234, 88, 12;
|
||||
--success: 34, 197, 94;
|
||||
--warning: 234, 179, 8;
|
||||
--danger: 239, 68, 68;
|
||||
--info: 6, 182, 212;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
overscroll-behavior-y: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Mobile viewport height fix */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.h-screen {
|
||||
height: -webkit-fill-available;
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: -webkit-fill-available;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for notched phones */
|
||||
.safe-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Touch action optimization */
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* Prevent text selection on buttons */
|
||||
button {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Leaflet container fix */
|
||||
.leaflet-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-pending { @apply bg-gray-100 text-gray-700 border-gray-300; }
|
||||
.status-assigned { @apply bg-blue-100 text-blue-700 border-blue-300; }
|
||||
.status-collected { @apply bg-green-100 text-green-700 border-green-300; }
|
||||
.status-problem { @apply bg-red-100 text-red-700 border-red-300; }
|
||||
|
||||
/* Card shadows */
|
||||
.card-shadow {
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||
}
|
||||
|
||||
.card-shadow-lg {
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Pull to refresh indicator */
|
||||
.pull-indicator {
|
||||
transition: transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
46
app/layout.tsx
Normal file
46
app/layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "1Driver - Collections",
|
||||
description: "Mobile driver collection app",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "1Driver",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#ea580c",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icons/icon.svg" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="theme-color" content="#ea580c" />
|
||||
</head>
|
||||
<body className="antialiased bg-gray-50 safe-top safe-bottom">
|
||||
<AuthProvider>
|
||||
{children}
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
113
app/login/page.tsx
Normal file
113
app/login/page.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { login } from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Truck, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
// Auth provider will detect the change and redirect
|
||||
window.location.href = '/';
|
||||
} catch (err) {
|
||||
setError('Invalid email or password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-orange-500 to-orange-700 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm bg-white rounded-2xl shadow-2xl p-6 animate-fade-in">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-orange-500 rounded-2xl mx-auto mb-4 flex items-center justify-center shadow-lg">
|
||||
<Truck size={32} className="text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">1Driver</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Collections App</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full p-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all"
|
||||
placeholder="driver@example.com"
|
||||
required
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full p-3 pr-12 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||
<p className="text-xs text-red-600 text-center">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
|
||||
'bg-orange-500 active:bg-orange-600 transition-colors',
|
||||
'touch-manipulation flex items-center justify-center gap-2',
|
||||
isLoading && 'opacity-70 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
app/map/page.tsx
Normal file
158
app/map/page.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import LocationTracker from '@/components/LocationTracker';
|
||||
import { getAssignedCollections, isAuthenticated } from '@/lib/pocketbase';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RefreshCw, MapPin, AlertCircle } from 'lucide-react';
|
||||
|
||||
// Dynamic import for Leaflet to avoid SSR issues
|
||||
const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
export default function MapPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, isLoading: authLoading } = useAuth();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) {
|
||||
router.push('/login/');
|
||||
}
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
const loadCollections = useCallback(async (showLoading = true) => {
|
||||
if (!isAuthenticated()) return;
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
setCollections(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [loadCollections]);
|
||||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let url: string;
|
||||
|
||||
if (isIOS) {
|
||||
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
|
||||
} else {
|
||||
url = `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-50">
|
||||
<LocationTracker />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-orange-600 text-white safe-top z-40 shadow-md flex-shrink-0">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={20} />
|
||||
<h1 className="text-lg font-bold">Collection Map</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-orange-700/50 px-2 py-1 rounded-full text-xs">
|
||||
{collections.length} stops
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRefreshing(true);
|
||||
loadCollections(false);
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={cn(isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Map */}
|
||||
<main className="flex-1 relative">
|
||||
{isLoading ? (
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
|
||||
<AlertCircle size={48} className="text-red-400 mb-3" />
|
||||
<p className="text-gray-600 text-sm mb-4 text-center">{error}</p>
|
||||
<button
|
||||
onClick={() => loadCollections()}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
|
||||
<MapPin size={48} className="text-gray-300 mb-3" />
|
||||
<p className="text-gray-600 font-medium">No collections to show</p>
|
||||
<p className="text-gray-400 text-sm mt-1">All collections completed!</p>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CollectionMap
|
||||
collections={collections}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
app/page.tsx
Normal file
432
app/page.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import LocationTracker from '@/components/LocationTracker';
|
||||
import CollectionItemCard from '@/components/CollectionItemCard';
|
||||
import CollectModal from '@/components/CollectModal';
|
||||
import ProblemModal from '@/components/ProblemModal';
|
||||
import {
|
||||
getAssignedCollections,
|
||||
updateCollectionStatus,
|
||||
reportProblem,
|
||||
isAuthenticated,
|
||||
logout,
|
||||
} from '@/lib/pocketbase';
|
||||
import { cn, formatDate } from '@/lib/utils';
|
||||
import {
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
MapPin,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Package,
|
||||
Search,
|
||||
X,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [filteredCollections, setFilteredCollections] = useState<Collection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
|
||||
const [showCollectModal, setShowCollectModal] = useState(false);
|
||||
const [showProblemModal, setShowProblemModal] = useState(false);
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'today' | 'tomorrow'>('all');
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) {
|
||||
router.push('/login/');
|
||||
}
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
const loadCollections = useCallback(async (showLoading = true) => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
if (showLoading) setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const data = await getAssignedCollections();
|
||||
setCollections(data);
|
||||
setFilteredCollections(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load collections:', err);
|
||||
setError('Failed to load collections. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, [loadCollections]);
|
||||
|
||||
// Filter collections
|
||||
useEffect(() => {
|
||||
let filtered = collections;
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((c) => {
|
||||
const desc = c.is_manual
|
||||
? c.description
|
||||
: c.expand?.order_item?.description || '';
|
||||
const supplier = c.expand?.supplier?.suppliername || '';
|
||||
const orderNum = c.expand?.order_item?.expand?.order?.order_number || '';
|
||||
return (
|
||||
desc.toLowerCase().includes(query) ||
|
||||
supplier.toLowerCase().includes(query) ||
|
||||
orderNum.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Date filter
|
||||
if (filter !== 'all') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
filtered = filtered.filter((c) => {
|
||||
if (!c.planned_date) return filter === 'today';
|
||||
const planned = new Date(c.planned_date);
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
|
||||
if (filter === 'today') {
|
||||
return planned.getTime() === today.getTime();
|
||||
} else if (filter === 'tomorrow') {
|
||||
return planned.getTime() === tomorrow.getTime();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredCollections(filtered);
|
||||
}, [collections, searchQuery, filter]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await loadCollections(false);
|
||||
};
|
||||
|
||||
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleCollect = async (collection: Collection, receivedQty: number, notes: string) => {
|
||||
try {
|
||||
await updateCollectionStatus(collection.id, 'collected', {
|
||||
received_qty: receivedQty,
|
||||
notes,
|
||||
actual_date: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Remove from list
|
||||
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
|
||||
setShowCollectModal(false);
|
||||
showToast('Collection completed successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to collect:', err);
|
||||
showToast('Failed to update collection', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleProblem = async (
|
||||
collection: Collection,
|
||||
problemType: string,
|
||||
description: string,
|
||||
photo?: File
|
||||
) => {
|
||||
try {
|
||||
await reportProblem(collection.id, problemType, description);
|
||||
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
|
||||
setShowProblemModal(false);
|
||||
showToast('Problem reported successfully', 'warning');
|
||||
} catch (err) {
|
||||
console.error('Failed to report problem:', err);
|
||||
showToast('Failed to report problem', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigate = (lat: number, lng: number, name: string) => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let url: string;
|
||||
|
||||
if (isIOS) {
|
||||
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
|
||||
} else {
|
||||
url = `https://www.google.com/maps?q=${lat},${lng}`;
|
||||
}
|
||||
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// Group by supplier
|
||||
const groupedBySupplier = filteredCollections.reduce((acc, collection) => {
|
||||
const supplier = collection.expand?.supplier;
|
||||
if (!supplier) return acc;
|
||||
|
||||
const key = supplier.id;
|
||||
if (!acc[key]) {
|
||||
acc[key] = { supplier, items: [] };
|
||||
}
|
||||
acc[key].items.push(collection);
|
||||
return acc;
|
||||
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['supplier']; items: Collection[] }>);
|
||||
|
||||
const groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
|
||||
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
|
||||
);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null; // Will redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
<LocationTracker />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-md">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Collections</h1>
|
||||
<p className="text-xs text-orange-100">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<RefreshCw
|
||||
size={18}
|
||||
className={cn(isRefreshing && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="px-4 pb-3">
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search collections..."
|
||||
className="w-full pl-9 pr-9 py-2 bg-white/10 border border-white/20 rounded-xl text-sm text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/30"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="px-4 pb-2 flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{[
|
||||
{ key: 'all' as const, label: 'All', icon: Package },
|
||||
{ key: 'today' as const, label: 'Today', icon: Clock },
|
||||
{ key: 'tomorrow' as const, label: 'Tomorrow', icon: Clock },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors',
|
||||
filter === key
|
||||
? 'bg-white text-orange-600'
|
||||
: 'bg-orange-700/50 text-orange-100'
|
||||
)}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{label}
|
||||
<span className="bg-orange-800/50 px-1.5 py-0.5 rounded-full text-[10px]">
|
||||
{key === 'all'
|
||||
? collections.length
|
||||
: key === 'today'
|
||||
? collections.filter((c) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const planned = c.planned_date ? new Date(c.planned_date) : today;
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
return planned.getTime() === today.getTime();
|
||||
}).length
|
||||
: collections.filter((c) => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const planned = c.planned_date ? new Date(c.planned_date) : null;
|
||||
if (!planned) return false;
|
||||
planned.setHours(0, 0, 0, 0);
|
||||
return planned.getTime() === tomorrow.getTime();
|
||||
}).length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="p-4">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-xl p-4 card-shadow">
|
||||
<div className="skeleton h-4 w-3/4 rounded mb-2" />
|
||||
<div className="skeleton h-3 w-1/2 rounded mb-1" />
|
||||
<div className="skeleton h-3 w-2/3 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<AlertCircle size={48} className="mx-auto text-red-400 mb-3" />
|
||||
<p className="text-gray-600 text-sm mb-4">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : groupedArray.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 size={48} className="mx-auto text-green-400 mb-3" />
|
||||
<p className="text-gray-600 font-medium">All caught up!</p>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{searchQuery
|
||||
? 'No collections match your search'
|
||||
: 'No assigned collections for this period'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groupedArray.map(({ supplier, items }) => (
|
||||
<div key={supplier!.id}>
|
||||
{/* Supplier Header */}
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<MapPin size={14} className="text-orange-500" />
|
||||
<h2 className="font-semibold text-sm text-gray-800">
|
||||
{supplier!.suppliername}
|
||||
</h2>
|
||||
<span className="bg-orange-100 text-orange-700 text-[10px] px-2 py-0.5 rounded-full font-medium">
|
||||
{items.length}
|
||||
</span>
|
||||
{supplier!.address && (
|
||||
<span className="text-xs text-gray-400 truncate flex-1 text-right">
|
||||
{supplier!.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-2">
|
||||
{items.map((collection) => (
|
||||
<CollectionItemCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onCollect={(c) => {
|
||||
setSelectedCollection(c);
|
||||
setShowCollectModal(true);
|
||||
}}
|
||||
onProblem={(c) => {
|
||||
setSelectedCollection(c);
|
||||
setShowProblemModal(true);
|
||||
}}
|
||||
onNavigate={handleNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Modals */}
|
||||
<CollectModal
|
||||
collection={selectedCollection}
|
||||
isOpen={showCollectModal}
|
||||
onClose={() => {
|
||||
setShowCollectModal(false);
|
||||
setSelectedCollection(null);
|
||||
}}
|
||||
onConfirm={handleCollect}
|
||||
/>
|
||||
|
||||
<ProblemModal
|
||||
collection={selectedCollection}
|
||||
isOpen={showProblemModal}
|
||||
onClose={() => {
|
||||
setShowProblemModal(false);
|
||||
setSelectedCollection(null);
|
||||
}}
|
||||
onConfirm={handleProblem}
|
||||
/>
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
|
||||
<div
|
||||
className={cn(
|
||||
'px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center',
|
||||
toast.type === 'success' && 'bg-green-500',
|
||||
toast.type === 'error' && 'bg-red-500',
|
||||
toast.type === 'warning' && 'bg-amber-500'
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
app/settings/page.tsx
Normal file
362
app/settings/page.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import { getSettings, saveSettings, logout } from '@/lib/pocketbase';
|
||||
import { AppSettings, DEFAULT_SETTINGS } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Settings,
|
||||
Clock,
|
||||
Crosshair,
|
||||
Moon,
|
||||
Type,
|
||||
WifiOff,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 30, label: '30 seconds' },
|
||||
{ value: 60, label: '1 minute' },
|
||||
{ value: 120, label: '2 minutes' },
|
||||
{ value: 300, label: '5 minutes' },
|
||||
{ value: 600, label: '10 minutes' },
|
||||
];
|
||||
|
||||
const ACCURACY_OPTIONS = [
|
||||
{ value: 10, label: '10m (High)' },
|
||||
{ value: 50, label: '50m (Balanced)' },
|
||||
{ value: 100, label: '100m (Battery)' },
|
||||
{ value: 500, label: '500m (Low)' },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
|
||||
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isLoggedIn) {
|
||||
router.push('/login/');
|
||||
}
|
||||
}, [isLoggedIn, authLoading, router]);
|
||||
|
||||
useEffect(() => {
|
||||
setSettings(getSettings());
|
||||
}, []);
|
||||
|
||||
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
const updated = { ...settings, [key]: value };
|
||||
setSettings(updated);
|
||||
saveSettings({ [key]: value });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
const resetSettings = () => {
|
||||
if (confirm('Reset all settings to defaults?')) {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
saveSettings(DEFAULT_SETTINGS);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pb-20">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-900 text-white safe-top">
|
||||
<div className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-500 rounded-xl flex items-center justify-center">
|
||||
<Settings size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Settings</h1>
|
||||
<p className="text-xs text-gray-400">
|
||||
{user?.name || user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="p-4 space-y-6">
|
||||
{/* Location Settings */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<Crosshair size={16} className="text-orange-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
Location Tracking
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl card-shadow overflow-hidden">
|
||||
{/* Update Interval */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Update Interval
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
|
||||
{settings.locationInterval}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{INTERVAL_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => updateSetting('locationInterval', option.value)}
|
||||
className={cn(
|
||||
'py-2 px-2 rounded-lg text-xs font-medium transition-all',
|
||||
'touch-manipulation',
|
||||
settings.locationInterval === option.value
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-2">
|
||||
How often your location is sent to the server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Minimum Accuracy */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair size={16} className="text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Minimum Accuracy
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
|
||||
{settings.minimumAccuracy}m
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ACCURACY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => updateSetting('minimumAccuracy', option.value)}
|
||||
className={cn(
|
||||
'py-2 px-2 rounded-lg text-xs font-medium transition-all',
|
||||
'touch-manipulation',
|
||||
settings.minimumAccuracy === option.value
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-2">
|
||||
Skip location updates with worse accuracy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* High Accuracy Toggle */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair size={16} className="text-gray-400" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block">
|
||||
High Accuracy Mode
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Uses GPS for better precision
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)
|
||||
}
|
||||
className={cn(
|
||||
'w-12 h-7 rounded-full transition-colors relative',
|
||||
settings.enableHighAccuracy ? 'bg-orange-500' : 'bg-gray-300'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0.5 w-6 h-6 bg-white rounded-full shadow-md transition-transform',
|
||||
settings.enableHighAccuracy ? 'translate-x-5' : 'translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Display Settings */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<Type size={16} className="text-orange-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
Display
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl card-shadow overflow-hidden">
|
||||
{/* Font Size */}
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Type size={16} className="text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">Font Size</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['small', 'normal', 'large'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => updateSetting('fontSize', size)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 rounded-lg text-sm font-medium transition-all',
|
||||
'touch-manipulation capitalize',
|
||||
settings.fontSize === size
|
||||
? 'bg-orange-500 text-white shadow-sm'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{size === 'small' && (
|
||||
<span className="text-xs">A</span>
|
||||
)}
|
||||
{size === 'normal' && (
|
||||
<span className="text-sm">A</span>
|
||||
)}
|
||||
{size === 'large' && (
|
||||
<span className="text-lg">A</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline Mode */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<WifiOff size={16} className="text-gray-400" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 block">
|
||||
Offline Queue
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-400">
|
||||
Queue actions when offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateSetting('offlineMode', !settings.offlineMode)
|
||||
}
|
||||
className={cn(
|
||||
'w-12 h-7 rounded-full transition-colors relative',
|
||||
settings.offlineMode ? 'bg-orange-500' : 'bg-gray-300'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0.5 w-6 h-6 bg-white rounded-full shadow-md transition-transform',
|
||||
settings.offlineMode ? 'translate-x-5' : 'translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* About */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<Info size={16} className="text-orange-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
|
||||
About
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl card-shadow overflow-hidden">
|
||||
<div className="p-4 text-center">
|
||||
<h3 className="text-lg font-bold text-gray-800">1Driver</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">Version 1.0.0</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Collections Management App
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className={cn(
|
||||
'w-full py-3 px-4 rounded-xl text-sm font-medium',
|
||||
'bg-gray-200 text-gray-700 active:bg-gray-300',
|
||||
'touch-manipulation flex items-center justify-center gap-2'
|
||||
)}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Reset to Defaults
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to sign out?')) {
|
||||
logout();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
|
||||
'bg-red-500 active:bg-red-600 transition-colors',
|
||||
'touch-manipulation flex items-center justify-center gap-2'
|
||||
)}
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Saved Toast */}
|
||||
{saved && (
|
||||
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
|
||||
<div className="px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center bg-green-500 flex items-center justify-center gap-2">
|
||||
<Save size={16} />
|
||||
Settings saved
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
components/AuthProvider.tsx
Normal file
52
components/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { pb, getCurrentUser, isAuthenticated } from '@/lib/pocketbase';
|
||||
|
||||
interface AuthContextType {
|
||||
isLoggedIn: boolean;
|
||||
user: ReturnType<typeof getCurrentUser>;
|
||||
isLoading: boolean;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
isLoggedIn: false,
|
||||
user: null,
|
||||
isLoading: true,
|
||||
refresh: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState<ReturnType<typeof getCurrentUser>>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refresh = () => {
|
||||
const valid = isAuthenticated();
|
||||
setIsLoggedIn(valid);
|
||||
setUser(getCurrentUser());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
setIsLoading(false);
|
||||
|
||||
// Listen for auth store changes
|
||||
const unsubscribe = pb.authStore.onChange(() => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isLoggedIn, user, isLoading, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
56
components/BottomNav.tsx
Normal file
56
components/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { List, Map, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: 'List', icon: List },
|
||||
{ href: '/map/', label: 'Map', icon: Map },
|
||||
{ href: '/settings/', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
export default function BottomNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-bottom shadow-lg">
|
||||
<div className="flex justify-around items-center h-16">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center flex-1 h-full touch-manipulation',
|
||||
'active:scale-95 transition-transform duration-150',
|
||||
isActive ? 'text-primary-600' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={22}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
className={cn(
|
||||
'transition-colors duration-200',
|
||||
isActive && 'drop-shadow-sm'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 font-medium transition-colors duration-200',
|
||||
isActive ? 'text-primary-600' : 'text-gray-400'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
149
components/CollectModal.tsx
Normal file
149
components/CollectModal.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { cn, formatCurrency } from '@/lib/utils';
|
||||
import { X, Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface CollectModalProps {
|
||||
collection: Collection | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (collection: Collection, receivedQty: number, notes: string) => void;
|
||||
}
|
||||
|
||||
export default function CollectModal({
|
||||
collection,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: CollectModalProps) {
|
||||
const [receivedQty, setReceivedQty] = useState<string>('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen || !collection) return null;
|
||||
|
||||
const expectedQty = collection.quantity || collection.expand?.order_item?.quantity || 0;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const qty = parseFloat(receivedQty);
|
||||
if (isNaN(qty) || qty <= 0) {
|
||||
setError('Please enter a valid quantity');
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm(collection, qty, notes);
|
||||
setReceivedQty('');
|
||||
setNotes('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const adjustQty = (delta: number) => {
|
||||
const current = parseFloat(receivedQty) || 0;
|
||||
const newQty = Math.max(0, current + delta);
|
||||
setReceivedQty(newQty.toString());
|
||||
};
|
||||
|
||||
const isPartial = parseFloat(receivedQty) > 0 && parseFloat(receivedQty) < expectedQty;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl w-full max-w-md p-6 animate-slide-up">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold">Collect Item</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{collection.is_manual
|
||||
? collection.description
|
||||
: collection.expand?.order_item?.description || 'Unknown item'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Expected: {expectedQty} {collection.unit || collection.expand?.order_item?.unit || 'units'}
|
||||
</p>
|
||||
{collection.expand?.order_item?.selling_price && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Unit price: {formatCurrency(collection.expand.order_item.selling_price)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Received Quantity *
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustQty(-1)}
|
||||
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Minus size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={receivedQty}
|
||||
onChange={(e) => setReceivedQty(e.target.value)}
|
||||
className="flex-1 p-3 text-center text-lg font-semibold border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="0.00"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => adjustQty(1)}
|
||||
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{isPartial && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
Partial collection: {receivedQty} of {expectedQty} units
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full p-3 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
|
||||
rows={2}
|
||||
placeholder="Add any notes..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
|
||||
'bg-green-500 active:bg-green-600 transition-colors',
|
||||
'touch-manipulation flex items-center justify-center gap-2'
|
||||
)}
|
||||
>
|
||||
Confirm Collection
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
components/CollectionItemCard.tsx
Normal file
240
components/CollectionItemCard.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection } from '@/lib/types';
|
||||
import { cn, formatDate, formatCurrency } from '@/lib/utils';
|
||||
import {
|
||||
Check,
|
||||
AlertTriangle,
|
||||
Navigation,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Package,
|
||||
Wrench,
|
||||
Hammer,
|
||||
ShoppingCart,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface CollectionItemProps {
|
||||
collection: Collection;
|
||||
onCollect: (collection: Collection) => void;
|
||||
onProblem: (collection: Collection) => void;
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function getItemType(collection: Collection): {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
label: string;
|
||||
} {
|
||||
if (collection.status === 'problem') {
|
||||
return {
|
||||
type: 'problem',
|
||||
icon: <AlertTriangle size={16} />,
|
||||
color: 'text-red-500',
|
||||
label: 'Problem',
|
||||
};
|
||||
}
|
||||
|
||||
const orderNumber = collection.expand?.order_item?.expand?.order?.order_number || '';
|
||||
const desc = collection.description || collection.expand?.order_item?.description || '';
|
||||
|
||||
if (orderNumber.toLowerCase().includes('breakdown')) {
|
||||
return {
|
||||
type: 'breakdown',
|
||||
icon: <Hammer size={16} />,
|
||||
color: 'text-orange-600',
|
||||
label: 'Breakdown',
|
||||
};
|
||||
}
|
||||
|
||||
if (orderNumber.toLowerCase().includes('repair')) {
|
||||
return {
|
||||
type: 'repair',
|
||||
icon: <Wrench size={16} />,
|
||||
color: 'text-amber-500',
|
||||
label: 'Repair',
|
||||
};
|
||||
}
|
||||
|
||||
if (collection.is_manual) {
|
||||
return {
|
||||
type: 'manual',
|
||||
icon: <Package size={16} />,
|
||||
color: 'text-purple-500',
|
||||
label: 'Manual',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'normal',
|
||||
icon: <ShoppingCart size={16} />,
|
||||
color: 'text-orange-500',
|
||||
label: 'Order',
|
||||
};
|
||||
}
|
||||
|
||||
export default function CollectionItemCard({
|
||||
collection,
|
||||
onCollect,
|
||||
onProblem,
|
||||
onNavigate,
|
||||
}: CollectionItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const supplier = collection.expand?.supplier;
|
||||
const orderItem = collection.expand?.order_item;
|
||||
const itemType = getItemType(collection);
|
||||
|
||||
const hasCoords = supplier?.latitude && supplier?.longitude;
|
||||
|
||||
const description = collection.is_manual
|
||||
? collection.description
|
||||
: orderItem?.description || 'Unknown item';
|
||||
|
||||
const quantity = collection.quantity || orderItem?.quantity || 0;
|
||||
const unit = collection.unit || orderItem?.unit || 'units';
|
||||
|
||||
const orderNumber = orderItem?.expand?.order?.order_number || '';
|
||||
const poNumber = orderItem?.expand?.purchase_order?.po_number || '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-xl card-shadow mb-3 overflow-hidden animate-fade-in',
|
||||
collection.status === 'problem' && 'border-l-4 border-red-500',
|
||||
collection.status === 'assigned' && 'border-l-4 border-orange-500',
|
||||
collection.status === 'pending' && 'border-l-4 border-gray-300'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer active:bg-gray-50 transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={cn('flex-shrink-0', itemType.color)}>
|
||||
{itemType.icon}
|
||||
</span>
|
||||
<h3 className="font-semibold text-sm leading-tight truncate">
|
||||
{description}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0',
|
||||
itemType.type === 'problem' && 'bg-red-100 text-red-700',
|
||||
itemType.type === 'breakdown' && 'bg-orange-100 text-orange-700',
|
||||
itemType.type === 'repair' && 'bg-amber-100 text-amber-700',
|
||||
itemType.type === 'manual' && 'bg-purple-100 text-purple-700',
|
||||
itemType.type === 'normal' && 'bg-orange-100 text-orange-700'
|
||||
)}
|
||||
>
|
||||
{itemType.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
|
||||
<p className="font-medium text-gray-700">
|
||||
{supplier?.suppliername || 'Unknown Supplier'}
|
||||
</p>
|
||||
<p>Expected: {quantity} {unit}</p>
|
||||
{orderNumber && <p>Order: {orderNumber}</p>}
|
||||
{poNumber && <p>PO: {poNumber}</p>}
|
||||
{collection.planned_date && (
|
||||
<p>Planned: {formatDate(collection.planned_date)}</p>
|
||||
)}
|
||||
{orderItem?.selling_price && (
|
||||
<p className="text-green-600 font-medium">
|
||||
{formatCurrency(orderItem.selling_price * quantity)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2 ml-2">
|
||||
{expanded ? (
|
||||
<ChevronUp size={20} className="text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100 pt-3">
|
||||
{collection.notes && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-3">
|
||||
<p className="text-xs text-yellow-800 flex items-start gap-1.5">
|
||||
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
<span>{collection.notes}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{supplier?.address && (
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
<strong>Address:</strong> {supplier.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCollect(collection);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
|
||||
'bg-green-500 text-white active:bg-green-600 transition-colors',
|
||||
'touch-manipulation'
|
||||
)}
|
||||
>
|
||||
<Check size={16} strokeWidth={2.5} />
|
||||
<span>Collect</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onProblem(collection);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
|
||||
'bg-red-500 text-white active:bg-red-600 transition-colors',
|
||||
'touch-manipulation'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle size={16} strokeWidth={2.5} />
|
||||
<span>Problem</span>
|
||||
</button>
|
||||
|
||||
{hasCoords && supplier && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
|
||||
'bg-orange-500 text-white active:bg-orange-600 transition-colors',
|
||||
'touch-manipulation'
|
||||
)}
|
||||
>
|
||||
<Navigation size={16} strokeWidth={2.5} />
|
||||
<span>Navigate</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
components/CollectionMap.tsx
Normal file
222
components/CollectionMap.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { Collection, Supplier } from '@/lib/types';
|
||||
import { formatCurrency, calculateDistance } from '@/lib/utils';
|
||||
import { Navigation, Check, AlertTriangle } from 'lucide-react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
// Fix Leaflet default markers in Next.js
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
const currentLocationIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: `
|
||||
<div style="
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ea580c;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
"></div>
|
||||
`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
});
|
||||
|
||||
interface MapViewProps {
|
||||
collections: Collection[];
|
||||
onNavigate: (lat: number, lng: number, name: string) => void;
|
||||
}
|
||||
|
||||
function MapBounds({ collections }: { collections: Collection[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const validPoints = collections
|
||||
.filter((c) => c.expand?.supplier?.latitude && c.expand?.supplier?.longitude)
|
||||
.map((c) => [c.expand!.supplier!.latitude!, c.expand!.supplier!.longitude!] as [number, number]);
|
||||
|
||||
if (validPoints.length > 0) {
|
||||
const bounds = L.latLngBounds(validPoints);
|
||||
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
|
||||
}
|
||||
}, [map, collections]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CollectionMap({ collections, onNavigate }: MapViewProps) {
|
||||
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if ('geolocation' in navigator) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||
},
|
||||
(error) => console.error('Could not get location:', error),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||
},
|
||||
(error) => console.error('Watch error:', error),
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
|
||||
return () => navigator.geolocation.clearWatch(watchId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Group collections by supplier
|
||||
const suppliers = useMemo(() => {
|
||||
const map = new Map<string, { supplier: Supplier; collections: Collection[] }>();
|
||||
|
||||
collections.forEach((collection) => {
|
||||
if (!collection.expand?.supplier) return;
|
||||
const supplier = collection.expand.supplier;
|
||||
if (!supplier.latitude || !supplier.longitude) return;
|
||||
|
||||
const key = supplier.id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { supplier, collections: [] });
|
||||
}
|
||||
map.get(key)!.collections.push(collection);
|
||||
});
|
||||
|
||||
return Array.from(map.values());
|
||||
}, [collections]);
|
||||
|
||||
const defaultCenter: [number, number] = [-33.9249, 18.4241]; // Cape Town default
|
||||
const center = userLocation || defaultCenter;
|
||||
|
||||
const getSupplierDistance = (supplier: Supplier) => {
|
||||
if (!userLocation || !supplier.latitude || !supplier.longitude) return null;
|
||||
return calculateDistance(
|
||||
userLocation[0],
|
||||
userLocation[1],
|
||||
supplier.latitude,
|
||||
supplier.longitude
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] w-full relative">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={12}
|
||||
className="h-full w-full"
|
||||
zoomControl={false}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<MapBounds collections={collections} />
|
||||
|
||||
{userLocation && (
|
||||
<Marker position={userLocation} icon={currentLocationIcon}>
|
||||
<Popup>
|
||||
<div className="text-sm font-medium">Your Location</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{suppliers.map(({ supplier, collections: supplierCollections }) => {
|
||||
const distance = getSupplierDistance(supplier);
|
||||
const hasProblem = supplierCollections.some((c) => c.status === 'problem');
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={supplier.id}
|
||||
position={[supplier.latitude!, supplier.longitude!]}
|
||||
icon={defaultIcon}
|
||||
eventHandlers={{
|
||||
click: () => setSelectedSupplier(supplier.id),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] max-w-[250px]">
|
||||
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
||||
<p className="text-xs text-gray-500 mb-2">{supplier.address}</p>
|
||||
|
||||
{distance && (
|
||||
<p className="text-xs text-orange-600 mb-2 font-medium">
|
||||
{distance.toFixed(1)} km away
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{supplierCollections.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="text-xs p-1.5 bg-gray-50 rounded"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{c.status === 'problem' && (
|
||||
<AlertTriangle size={12} className="text-red-500" />
|
||||
)}
|
||||
<span className="font-medium truncate">
|
||||
{c.is_manual
|
||||
? c.description
|
||||
: c.expand?.order_item?.description || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-400">
|
||||
Qty: {c.quantity} {c.unit || c.expand?.order_item?.unit || 'units'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername)
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-blue-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
|
||||
>
|
||||
<Navigation size={14} />
|
||||
Navigate
|
||||
</button>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay */}
|
||||
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg z-[400]">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-blue-500 border-2 border-white shadow-sm" />
|
||||
<span className="text-[10px] text-gray-600">Your Location</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 relative">
|
||||
<div className="absolute inset-0 bg-blue-500 rounded-sm rotate-45 transform origin-bottom" />
|
||||
</div>
|
||||
<span className="text-[10px] text-gray-600">Collection Stop</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
components/LocationTracker.tsx
Normal file
111
components/LocationTracker.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { getSettings, saveDriverLocation, addToQueue, isAuthenticated } from '@/lib/pocketbase';
|
||||
|
||||
export default function LocationTracker() {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const watchRef = useRef<number | null>(null);
|
||||
|
||||
const sendLocation = useCallback(async (position: GeolocationPosition) => {
|
||||
if (!isAuthenticated()) return;
|
||||
|
||||
const user = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}')?.model;
|
||||
if (!user) return;
|
||||
|
||||
const locationData = {
|
||||
driver: user.id,
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
altitude: position.coords.altitude || undefined,
|
||||
speed: position.coords.speed || undefined,
|
||||
heading: position.coords.heading || undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
await saveDriverLocation(locationData);
|
||||
} catch (error) {
|
||||
console.error('Failed to save location:', error);
|
||||
// Queue for sync when back online
|
||||
addToQueue({
|
||||
type: 'status_update',
|
||||
data: locationData,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!('geolocation' in navigator)) {
|
||||
console.warn('Geolocation not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const intervalMs = Math.max(10000, settings.locationInterval * 1000);
|
||||
|
||||
const startTracking = () => {
|
||||
// Use watchPosition for more frequent updates
|
||||
watchRef.current = navigator.geolocation.watchPosition(
|
||||
sendLocation,
|
||||
(error) => {
|
||||
console.error('Geolocation error:', error);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: settings.enableHighAccuracy,
|
||||
maximumAge: intervalMs,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
|
||||
// Also send on interval as backup
|
||||
intervalRef.current = setInterval(() => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
sendLocation,
|
||||
(error) => console.error('Interval geolocation error:', error),
|
||||
{
|
||||
enableHighAccuracy: settings.enableHighAccuracy,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
// Start tracking if authenticated
|
||||
if (isAuthenticated()) {
|
||||
startTracking();
|
||||
}
|
||||
|
||||
// Listen for auth changes
|
||||
const handleAuthChange = () => {
|
||||
if (isAuthenticated()) {
|
||||
startTracking();
|
||||
} else {
|
||||
stopTracking();
|
||||
}
|
||||
};
|
||||
|
||||
// Check periodically for auth state
|
||||
const authCheckInterval = setInterval(handleAuthChange, 5000);
|
||||
|
||||
const stopTracking = () => {
|
||||
if (watchRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchRef.current);
|
||||
watchRef.current = null;
|
||||
}
|
||||
if (intervalRef.current !== null) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
stopTracking();
|
||||
clearInterval(authCheckInterval);
|
||||
};
|
||||
}, [sendLocation]);
|
||||
|
||||
// This component doesn't render anything
|
||||
return null;
|
||||
}
|
||||
170
components/ProblemModal.tsx
Normal file
170
components/ProblemModal.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Collection, PROBLEM_TYPES } from '@/lib/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { X, Camera } from 'lucide-react';
|
||||
|
||||
interface ProblemModalProps {
|
||||
collection: Collection | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (collection: Collection, problemType: string, description: string, photo?: File) => void;
|
||||
}
|
||||
|
||||
export default function ProblemModal({
|
||||
collection,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ProblemModalProps) {
|
||||
const [problemType, setProblemType] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [photo, setPhoto] = useState<File | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!isOpen || !collection) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!problemType) {
|
||||
setError('Please select a problem type');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!description.trim()) {
|
||||
setError('Please describe the problem');
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm(collection, problemType, description, photo || undefined);
|
||||
setProblemType('');
|
||||
setDescription('');
|
||||
setPhoto(null);
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl w-full max-w-md p-6 animate-slide-up">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold">Report Problem</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-3 bg-red-50 rounded-lg">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{collection.is_manual
|
||||
? collection.description
|
||||
: collection.expand?.order_item?.description || 'Unknown item'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{collection.expand?.supplier?.suppliername || 'Unknown Supplier'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Problem Type *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{PROBLEM_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProblemType(type.value);
|
||||
setError('');
|
||||
}}
|
||||
className={cn(
|
||||
'py-2.5 px-3 rounded-lg text-sm text-left border-2 transition-all',
|
||||
'touch-manipulation',
|
||||
problemType === type.value
|
||||
? 'border-red-500 bg-red-50 text-red-700 font-medium'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="w-full p-3 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
placeholder="Describe the problem in detail..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Photo (optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => setPhoto(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
id="problem-photo"
|
||||
/>
|
||||
<label
|
||||
htmlFor="problem-photo"
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 border-dashed cursor-pointer',
|
||||
'transition-colors touch-manipulation',
|
||||
photo
|
||||
? 'border-green-400 bg-green-50 text-green-700'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
)}
|
||||
>
|
||||
<Camera size={20} />
|
||||
<span className="text-sm">
|
||||
{photo ? photo.name : 'Take or choose photo'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-2 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-xs text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
|
||||
'bg-red-500 active:bg-red-600 transition-colors',
|
||||
'touch-manipulation flex items-center justify-center gap-2'
|
||||
)}
|
||||
>
|
||||
Report Problem
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
lib/pocketbase.ts
Normal file
149
lib/pocketbase.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
import { AppSettings, DEFAULT_SETTINGS, Collection, DriverLocation } from './types';
|
||||
|
||||
const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
|
||||
|
||||
export const pb = new PocketBase(POCKETBASE_URL);
|
||||
|
||||
// Auth helpers
|
||||
export const isAuthenticated = () => pb.authStore.isValid;
|
||||
|
||||
export const getCurrentUser = () => pb.authStore.model;
|
||||
|
||||
export const logout = () => {
|
||||
pb.authStore.clear();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login/';
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (email: string, password: string) => {
|
||||
return await pb.collection('users').authWithPassword(email, password);
|
||||
};
|
||||
|
||||
// Collection data helpers
|
||||
export const getAssignedCollections = async () => {
|
||||
const user = getCurrentUser();
|
||||
if (!user) return [];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 2);
|
||||
|
||||
const records = await pb.collection('collections').getFullList({
|
||||
filter: `status = "assigned" && driver = "${user.id}" && planned_date <= "${tomorrow.toISOString()}"`,
|
||||
expand: 'order_item,supplier',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
|
||||
return records as unknown as Collection[];
|
||||
};
|
||||
|
||||
export const getPendingCollections = async () => {
|
||||
const records = await pb.collection('collections').getFullList({
|
||||
filter: 'status = "pending" || status = "problem"',
|
||||
expand: 'order_item,supplier',
|
||||
sort: 'supplier.suppliername',
|
||||
});
|
||||
|
||||
return records as unknown as Collection[];
|
||||
};
|
||||
|
||||
export const updateCollectionStatus = async (
|
||||
id: string,
|
||||
status: string,
|
||||
data: Partial<Collection>
|
||||
) => {
|
||||
return await pb.collection('collections').update(id, {
|
||||
status,
|
||||
...data,
|
||||
});
|
||||
};
|
||||
|
||||
export const reportProblem = async (
|
||||
collectionId: string,
|
||||
problemType: string,
|
||||
description: string
|
||||
) => {
|
||||
// Update collection status
|
||||
await pb.collection('collections').update(collectionId, {
|
||||
status: 'problem',
|
||||
notes: description,
|
||||
});
|
||||
|
||||
// Create problem record
|
||||
return await pb.collection('collection_problems').create({
|
||||
collection: collectionId,
|
||||
problem_type: problemType,
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
// Location tracking
|
||||
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
|
||||
return await pb.collection('driver_locations').create(location);
|
||||
};
|
||||
|
||||
// Settings helpers
|
||||
const SETTINGS_KEY = '1driver_settings';
|
||||
|
||||
export const getSettings = (): AppSettings => {
|
||||
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
|
||||
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: Partial<AppSettings>) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const current = getSettings();
|
||||
const updated = { ...current, ...settings };
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
|
||||
return updated;
|
||||
};
|
||||
|
||||
// Offline queue helpers
|
||||
const QUEUE_KEY = '1driver_offline_queue';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
type: 'status_update' | 'problem_report';
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export const addToQueue = (item: Omit<QueueItem, 'id' | 'timestamp'>) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const queue = getQueue();
|
||||
const newItem: QueueItem = {
|
||||
...item,
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
queue.push(newItem);
|
||||
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
||||
};
|
||||
|
||||
export const getQueue = (): QueueItem[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const saved = localStorage.getItem(QUEUE_KEY);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
};
|
||||
|
||||
export const removeFromQueue = (id: string) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const queue = getQueue().filter((item) => item.id !== id);
|
||||
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
|
||||
};
|
||||
|
||||
export const clearQueue = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(QUEUE_KEY);
|
||||
};
|
||||
103
lib/types.ts
Normal file
103
lib/types.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export interface Supplier {
|
||||
id: string;
|
||||
suppliername: string;
|
||||
address: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
collection?: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
order_qty: number;
|
||||
unit: string;
|
||||
selling_price?: number;
|
||||
order?: string;
|
||||
purchase_order?: string;
|
||||
item_status: string;
|
||||
expand?: {
|
||||
order?: { order_number: string };
|
||||
purchase_order?: { po_number: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface Collection {
|
||||
id: string;
|
||||
order_item: string;
|
||||
supplier: string;
|
||||
driver: string;
|
||||
status: 'pending' | 'assigned' | 'collected' | 'problem';
|
||||
quantity: number;
|
||||
planned_date: string;
|
||||
actual_date: string;
|
||||
received_qty: number;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_manual: boolean;
|
||||
description: string;
|
||||
unit: string;
|
||||
// Expanded fields
|
||||
expand?: {
|
||||
order_item?: OrderItem;
|
||||
supplier?: Supplier;
|
||||
driver?: Driver;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectionProblem {
|
||||
id: string;
|
||||
collection: string;
|
||||
problem_type: 'out_of_stock' | 'partial_stock' | 'quality_issue' | 'wrong_item' | 'other';
|
||||
description: string;
|
||||
photo?: string;
|
||||
reported_at: string;
|
||||
}
|
||||
|
||||
export interface Driver {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DriverLocation {
|
||||
id: string;
|
||||
driver: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
altitude?: number;
|
||||
speed?: number;
|
||||
heading?: number;
|
||||
timestamp: string;
|
||||
battery_level?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
locationInterval: number; // in seconds
|
||||
minimumAccuracy: number; // in meters
|
||||
enableHighAccuracy: boolean;
|
||||
darkMode: boolean;
|
||||
fontSize: 'small' | 'normal' | 'large';
|
||||
offlineMode: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
locationInterval: 60,
|
||||
minimumAccuracy: 50,
|
||||
enableHighAccuracy: true,
|
||||
darkMode: false,
|
||||
fontSize: 'normal',
|
||||
offlineMode: false,
|
||||
};
|
||||
|
||||
export const PROBLEM_TYPES = [
|
||||
{ value: 'out_of_stock', label: 'Out of Stock' },
|
||||
{ value: 'partial_stock', label: 'Partial Stock' },
|
||||
{ value: 'quality_issue', label: 'Quality Issue' },
|
||||
{ value: 'wrong_item', label: 'Wrong Item' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const;
|
||||
86
lib/utils.ts
Normal file
86
lib/utils.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string): string {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-ZA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDateTime(dateString: string): string {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-ZA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-ZA', {
|
||||
style: 'currency',
|
||||
currency: 'ZAR',
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return 'Just now';
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||
return formatDate(dateString);
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
|
||||
return array.reduce((result, item) => {
|
||||
const groupKey = String(item[key]);
|
||||
if (!result[groupKey]) {
|
||||
result[groupKey] = [];
|
||||
}
|
||||
result[groupKey].push(item);
|
||||
return result;
|
||||
}, {} as Record<string, T[]>);
|
||||
}
|
||||
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
11
next.config.js
Normal file
11
next.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'export',
|
||||
distDir: 'dist',
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
trailingSlash: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
34
nginx.conf
Normal file
34
nginx.conf
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# PWA manifest and service worker - no cache
|
||||
location ~* (manifest\.json|sw\.js)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
# SPA fallback - all routes serve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
}
|
||||
6367
package-lock.json
generated
Normal file
6367
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
34
package.json
Normal file
34
package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "1driver",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"pocketbase": "^0.21.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"lucide-react": "^0.378.0",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/leaflet": "^1.9.12",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^14.2.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
7
public/icons/icon.svg
Normal file
7
public/icons/icon.svg
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="64" fill="#ea580c"/>
|
||||
<circle cx="256" cy="200" r="80" fill="white" opacity="0.9"/>
|
||||
<path d="M256 300 L256 380 M256 380 L200 440 M256 380 L312 440" stroke="white" stroke-width="24" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="256" cy="200" r="40" fill="#ea580c"/>
|
||||
<path d="M236 200 L256 180 L276 200 L256 220 Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 445 B |
18
public/manifest.json
Normal file
18
public/manifest.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "1Driver Collections",
|
||||
"short_name": "1Driver",
|
||||
"description": "Mobile driver collection app",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f5f5",
|
||||
"theme_color": "#ea580c",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable any"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
tailwind.config.js
Normal file
41
tailwind.config.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#fff7ed',
|
||||
100: '#ffedd5',
|
||||
200: '#fed7aa',
|
||||
300: '#fdba74',
|
||||
400: '#fb923c',
|
||||
500: '#f97316',
|
||||
600: '#ea580c',
|
||||
700: '#c2410c',
|
||||
800: '#9a3412',
|
||||
900: '#7c2d12',
|
||||
},
|
||||
},
|
||||
spacing: {
|
||||
'18': '4.5rem',
|
||||
'22': '5.5rem',
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out forwards',
|
||||
'slide-up': 'slideUp 0.3s ease-out forwards',
|
||||
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
40
tsconfig.json
Normal file
40
tsconfig.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"dist/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue