From b738bc7495431d95cbc3d4248de80e4fa507b4cc Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sun, 24 May 2026 21:51:29 +0200 Subject: [PATCH] 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. --- .dockerignore | 11 + .env.example | 5 + .gitignore | 4 + .vscode/settings.json | 14 + Dockerfile | 39 + README.md | 108 + app/globals.css | 128 + app/layout.tsx | 46 + app/login/page.tsx | 113 + app/map/page.tsx | 158 + app/page.tsx | 432 ++ app/settings/page.tsx | 362 ++ components/AuthProvider.tsx | 52 + components/BottomNav.tsx | 56 + components/CollectModal.tsx | 149 + components/CollectionItemCard.tsx | 240 ++ components/CollectionMap.tsx | 222 + components/LocationTracker.tsx | 111 + components/ProblemModal.tsx | 170 + lib/pocketbase.ts | 149 + lib/types.ts | 103 + lib/utils.ts | 86 + next-env.d.ts | 5 + next.config.js | 11 + nginx.conf | 34 + package-lock.json | 6367 +++++++++++++++++++++++++++++ package.json | 34 + postcss.config.js | 6 + public/icons/icon.svg | 7 + public/manifest.json | 18 + tailwind.config.js | 41 + tsconfig.json | 40 + 32 files changed, 9321 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/map/page.tsx create mode 100644 app/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 components/AuthProvider.tsx create mode 100644 components/BottomNav.tsx create mode 100644 components/CollectModal.tsx create mode 100644 components/CollectionItemCard.tsx create mode 100644 components/CollectionMap.tsx create mode 100644 components/LocationTracker.tsx create mode 100644 components/ProblemModal.tsx create mode 100644 lib/pocketbase.ts create mode 100644 lib/types.ts create mode 100644 lib/utils.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 public/icons/icon.svg create mode 100644 public/manifest.json create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7fd2fa3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +dist +.git +.gitignore +README.md +.env +.env.local +.env.*.local +*.log +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..344e6ba --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47c83f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +dist/ +.env \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a338967 --- /dev/null +++ b/.vscode/settings.json @@ -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 + } +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b293ec1 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f38a58 --- /dev/null +++ b/README.md @@ -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 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..33c8fb8 --- /dev/null +++ b/app/globals.css @@ -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; } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..5652308 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + + + + {children} + + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..15ef9b7 --- /dev/null +++ b/app/login/page.tsx @@ -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 ( +
+
+ {/* Logo */} +
+
+ +
+

1Driver

+

Collections App

+
+ +
+
+ + 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" + /> +
+ +
+ +
+ 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 + /> + +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+
+ ); +} diff --git a/app/map/page.tsx b/app/map/page.tsx new file mode 100644 index 0000000..35aed69 --- /dev/null +++ b/app/map/page.tsx @@ -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: () => ( +
+
+
+ ), +}); + +export default function MapPage() { + const router = useRouter(); + const { isLoggedIn, isLoading: authLoading } = useAuth(); + const [collections, setCollections] = useState([]); + 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 ( +
+
+
+ ); + } + + if (!isLoggedIn) { + return null; + } + + return ( +
+ + + {/* Header */} +
+
+
+ +

Collection Map

+
+
+ + {collections.length} stops + + +
+
+
+ + {/* Map */} +
+ {isLoading ? ( +
+
+
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : collections.length === 0 ? ( +
+ +

No collections to show

+

All collections completed!

+
+ ) : ( + +
+
+ } + > + +
+ )} +
+ + +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..74aebf5 --- /dev/null +++ b/app/page.tsx @@ -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([]); + const [filteredCollections, setFilteredCollections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [error, setError] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCollection, setSelectedCollection] = useState(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['supplier']; items: Collection[] }>); + + const groupedArray = Object.values(groupedBySupplier).sort((a, b) => + (a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '') + ); + + if (authLoading) { + return ( +
+
+
+ ); + } + + if (!isLoggedIn) { + return null; // Will redirect + } + + return ( +
+ + + {/* Header */} +
+
+
+
+

Collections

+

+ {user?.name || user?.email} +

+
+
+ + +
+
+
+ + {/* Search Bar */} +
+
+ + 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 && ( + + )} +
+
+ + {/* Filter Tabs */} +
+ {[ + { 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 }) => ( + + ))} +
+
+ + {/* Content */} +
+ {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : error ? ( +
+ +

{error}

+ +
+ ) : groupedArray.length === 0 ? ( +
+ +

All caught up!

+

+ {searchQuery + ? 'No collections match your search' + : 'No assigned collections for this period'} +

+
+ ) : ( +
+ {groupedArray.map(({ supplier, items }) => ( +
+ {/* Supplier Header */} +
+ +

+ {supplier!.suppliername} +

+ + {items.length} + + {supplier!.address && ( + + {supplier!.address} + + )} +
+ + {/* Items */} +
+ {items.map((collection) => ( + { + setSelectedCollection(c); + setShowCollectModal(true); + }} + onProblem={(c) => { + setSelectedCollection(c); + setShowProblemModal(true); + }} + onNavigate={handleNavigate} + /> + ))} +
+
+ ))} +
+ )} +
+ + {/* Modals */} + { + setShowCollectModal(false); + setSelectedCollection(null); + }} + onConfirm={handleCollect} + /> + + { + setShowProblemModal(false); + setSelectedCollection(null); + }} + onConfirm={handleProblem} + /> + + {/* Toast */} + {toast && ( +
+
+ {toast.message} +
+
+ )} + + +
+ ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..9992447 --- /dev/null +++ b/app/settings/page.tsx @@ -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(DEFAULT_SETTINGS); + const [saved, setSaved] = useState(false); + + useEffect(() => { + if (!authLoading && !isLoggedIn) { + router.push('/login/'); + } + }, [isLoggedIn, authLoading, router]); + + useEffect(() => { + setSettings(getSettings()); + }, []); + + const updateSetting = (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 ( +
+
+
+ ); + } + + if (!isLoggedIn) { + return null; + } + + return ( +
+ {/* Header */} +
+
+
+
+ +
+
+

Settings

+

+ {user?.name || user?.email} +

+
+
+
+
+ +
+ {/* Location Settings */} +
+
+ +

+ Location Tracking +

+
+ +
+ {/* Update Interval */} +
+
+
+ + + Update Interval + +
+ + {settings.locationInterval}s + +
+
+ {INTERVAL_OPTIONS.map((option) => ( + + ))} +
+

+ How often your location is sent to the server +

+
+ + {/* Minimum Accuracy */} +
+
+
+ + + Minimum Accuracy + +
+ + {settings.minimumAccuracy}m + +
+
+ {ACCURACY_OPTIONS.map((option) => ( + + ))} +
+

+ Skip location updates with worse accuracy +

+
+ + {/* High Accuracy Toggle */} +
+
+
+ +
+ + High Accuracy Mode + + + Uses GPS for better precision + +
+
+ +
+
+
+
+ + {/* Display Settings */} +
+
+ +

+ Display +

+
+ +
+ {/* Font Size */} +
+
+ + Font Size +
+
+ {(['small', 'normal', 'large'] as const).map((size) => ( + + ))} +
+
+ + {/* Offline Mode */} +
+
+
+ +
+ + Offline Queue + + + Queue actions when offline + +
+
+ +
+
+
+
+ + {/* About */} +
+
+ +

+ About +

+
+ +
+
+

1Driver

+

Version 1.0.0

+

+ Collections Management App +

+
+
+
+ + {/* Actions */} +
+ + + +
+
+ + {/* Saved Toast */} + {saved && ( +
+
+ + Settings saved +
+
+ )} + + +
+ ); +} diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx new file mode 100644 index 0000000..8cc600f --- /dev/null +++ b/components/AuthProvider.tsx @@ -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; + isLoading: boolean; + refresh: () => void; +} + +const AuthContext = createContext({ + isLoggedIn: false, + user: null, + isLoading: true, + refresh: () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [user, setUser] = useState>(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 ( + + {children} + + ); +} + +export const useAuth = () => useContext(AuthContext); diff --git a/components/BottomNav.tsx b/components/BottomNav.tsx new file mode 100644 index 0000000..9701003 --- /dev/null +++ b/components/BottomNav.tsx @@ -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 ( + + ); +} diff --git a/components/CollectModal.tsx b/components/CollectModal.tsx new file mode 100644 index 0000000..e19677e --- /dev/null +++ b/components/CollectModal.tsx @@ -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(''); + 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 ( +
+
+
+
+

Collect Item

+ +
+ +
+

+ {collection.is_manual + ? collection.description + : collection.expand?.order_item?.description || 'Unknown item'} +

+

+ Expected: {expectedQty} {collection.unit || collection.expand?.order_item?.unit || 'units'} +

+ {collection.expand?.order_item?.selling_price && ( +

+ Unit price: {formatCurrency(collection.expand.order_item.selling_price)} +

+ )} +
+ +
+
+ +
+ + 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 + /> + +
+ {isPartial && ( +

+ Partial collection: {receivedQty} of {expectedQty} units +

+ )} + {error &&

{error}

} +
+ +
+ +