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