From 74f38bc3a15c1db5c2d8ed8de33d3fac6c26c917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cstevenmunoz=E2=80=9D?= <“steven.munoz90@gmail.com”> Date: Thu, 11 Dec 2025 19:56:16 -0500 Subject: [PATCH 01/31] style: fix Prettier formatting issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/src/components/RidesTable/RidesTable.css | 6 ++--- web/src/components/RidesTable/RidesTable.tsx | 10 +++++--- .../components/InDriverReviewTable.css | 8 +++--- .../components/InDriverReviewTable.tsx | 25 +++++++++++-------- web/src/hooks/useDriverRides.ts | 6 +---- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/web/src/components/RidesTable/RidesTable.css b/web/src/components/RidesTable/RidesTable.css index 409e6c0..b472d40 100644 --- a/web/src/components/RidesTable/RidesTable.css +++ b/web/src/components/RidesTable/RidesTable.css @@ -474,7 +474,7 @@ /* Time input styles */ .editable-time, -input[type="time"].editable-input { +input[type='time'].editable-input { font-family: 'JetBrains Mono', monospace; min-width: 120px; width: 120px; @@ -483,14 +483,14 @@ input[type="time"].editable-input { background-color: #fff !important; } -input[type="time"].editable-input::-webkit-calendar-picker-indicator { +input[type='time'].editable-input::-webkit-calendar-picker-indicator { cursor: pointer; padding: 4px; margin-left: 4px; opacity: 0.7; } -input[type="time"].editable-input::-webkit-calendar-picker-indicator:hover { +input[type='time'].editable-input::-webkit-calendar-picker-indicator:hover { opacity: 1; } diff --git a/web/src/components/RidesTable/RidesTable.tsx b/web/src/components/RidesTable/RidesTable.tsx index b3e4dc7..ead8c96 100644 --- a/web/src/components/RidesTable/RidesTable.tsx +++ b/web/src/components/RidesTable/RidesTable.tsx @@ -209,7 +209,11 @@ const EditableCell: FC = ({ } return ( - + {displayValue} @@ -544,9 +548,7 @@ export const RidesTable: FC = ({
Total Pagado - - {formatCurrency(totals.totalPaid)} - + {formatCurrency(totals.totalPaid)}
Ganancias Netas diff --git a/web/src/features/indriver-import/components/InDriverReviewTable.css b/web/src/features/indriver-import/components/InDriverReviewTable.css index 83ef240..4511bdf 100644 --- a/web/src/features/indriver-import/components/InDriverReviewTable.css +++ b/web/src/features/indriver-import/components/InDriverReviewTable.css @@ -550,14 +550,14 @@ } /* Ensure date input text is visible in all states */ -input[type="date"].editable-input { +input[type='date'].editable-input { color: #1e2a3a; -webkit-text-fill-color: #1e2a3a; } /* Time input styles */ .editable-time, -input[type="time"].editable-input { +input[type='time'].editable-input { font-family: 'JetBrains Mono', monospace; min-width: 120px; width: 120px; @@ -566,14 +566,14 @@ input[type="time"].editable-input { background-color: #fff !important; } -input[type="time"].editable-input::-webkit-calendar-picker-indicator { +input[type='time'].editable-input::-webkit-calendar-picker-indicator { cursor: pointer; padding: 4px; margin-left: 4px; opacity: 0.7; } -input[type="time"].editable-input::-webkit-calendar-picker-indicator:hover { +input[type='time'].editable-input::-webkit-calendar-picker-indicator:hover { opacity: 1; } diff --git a/web/src/features/indriver-import/components/InDriverReviewTable.tsx b/web/src/features/indriver-import/components/InDriverReviewTable.tsx index 7bf56c6..e901dc8 100644 --- a/web/src/features/indriver-import/components/InDriverReviewTable.tsx +++ b/web/src/features/indriver-import/components/InDriverReviewTable.tsx @@ -30,7 +30,17 @@ interface InDriverReviewTableProps { onBack: () => void; } -type EditableField = 'date' | 'time' | 'duration' | 'distance' | 'tarifa' | 'total_pagado' | 'comision_servicio' | 'iva_pago_servicio' | 'mis_ingresos' | 'status'; +type EditableField = + | 'date' + | 'time' + | 'duration' + | 'distance' + | 'tarifa' + | 'total_pagado' + | 'comision_servicio' + | 'iva_pago_servicio' + | 'mis_ingresos' + | 'status'; interface EditingState { rideId: string; @@ -342,10 +352,7 @@ export const InDriverReviewTable: FC = ({ ); }; - const renderStatusBadge = ( - ride: ExtractedInDriverRide, - isEditingStatus: boolean - ) => { + const renderStatusBadge = (ride: ExtractedInDriverRide, isEditingStatus: boolean) => { const statusOptions = [ { value: 'completed', label: 'Completado' }, { value: 'cancelled_by_passenger', label: 'Cancelado por pasajero' }, @@ -407,9 +414,7 @@ export const InDriverReviewTable: FC = ({ {formatConfidence(summary.average_confidence)}

)} -

- Haz clic en cualquier valor resaltado para editarlo -

+

Haz clic en cualquier valor resaltado para editarlo

@@ -422,9 +427,7 @@ export const InDriverReviewTable: FC = ({
Total Pagué - - {formatCurrency(totals.totalPagado)} - + {formatCurrency(totals.totalPagado)}
Total Neto diff --git a/web/src/hooks/useDriverRides.ts b/web/src/hooks/useDriverRides.ts index de9f644..39dda44 100644 --- a/web/src/hooks/useDriverRides.ts +++ b/web/src/hooks/useDriverRides.ts @@ -3,11 +3,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { - getInDriverRides, - updateInDriverRide, - type FirestoreInDriverRide, -} from '@/core/firebase'; +import { getInDriverRides, updateInDriverRide, type FirestoreInDriverRide } from '@/core/firebase'; interface UseDriverRidesOptions { startDate?: Date; From 12df76fba8a17477c33710846f98e481b329ef03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cstevenmunoz=E2=80=9D?= <“steven.munoz90@gmail.com”> Date: Thu, 11 Dec 2025 20:05:19 -0500 Subject: [PATCH 02/31] fix: prevent double /api/v1 path in API URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check if VITE_API_URL already ends with /api/v1 before appending it. This fixes the 404 error in DEV where the URL became /api/v1/api/v1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/src/core/config.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/core/config.ts b/web/src/core/config.ts index 581c7cd..4ea26af 100644 --- a/web/src/core/config.ts +++ b/web/src/core/config.ts @@ -5,8 +5,12 @@ // Only use localhost fallback in development mode const getApiUrl = (): string => { if (import.meta.env.VITE_API_URL) { - // Append /api/v1 to the base URL from environment - const baseUrl = import.meta.env.VITE_API_URL.replace(/\/$/, ''); // Remove trailing slash if any + // Remove trailing slash if any + const baseUrl = import.meta.env.VITE_API_URL.replace(/\/$/, ''); + // Only append /api/v1 if it's not already in the URL + if (baseUrl.endsWith('/api/v1')) { + return baseUrl; + } return `${baseUrl}/api/v1`; } // In production without API URL, return empty to prevent localhost calls From f7da5e2230e485de0c012f29df5be961bc17e493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cstevenmunoz=E2=80=9D?= <“steven.munoz90@gmail.com”> Date: Thu, 11 Dec 2025 21:35:42 -0500 Subject: [PATCH 03/31] feat: add cancellation breakdown filter and value warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add filter options for cancelled by passenger vs driver in StatusFilter - Show cancellation breakdown tooltip on dashboard summary cards - Highlight suspicious values (0 or < 100 COP) in InDriver import table - Fix dropdown alignment and button styling issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- web/src/components/RidesTable/RidesTable.css | 55 ++++++++++++++++++ web/src/components/RidesTable/RidesTable.tsx | 58 +++++++++++++++---- .../components/StatusFilter/StatusFilter.css | 2 + .../components/StatusFilter/StatusFilter.tsx | 4 +- .../components/InDriverReviewTable.css | 28 +++++++++ .../components/InDriverReviewTable.tsx | 32 ++++++++-- web/src/pages/DashboardPage.css | 14 +++-- 7 files changed, 174 insertions(+), 19 deletions(-) diff --git a/web/src/components/RidesTable/RidesTable.css b/web/src/components/RidesTable/RidesTable.css index b472d40..fd1fcd1 100644 --- a/web/src/components/RidesTable/RidesTable.css +++ b/web/src/components/RidesTable/RidesTable.css @@ -116,6 +116,61 @@ color: #f87171; } +.summary-card-with-tooltip { + position: relative; + cursor: pointer; +} + +.summary-tooltip { + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: #fff; + color: #1e2a3a; + padding: 0.75rem 1rem; + border-radius: 8px; + font-size: 0.8125rem; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.summary-tooltip::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-bottom-color: #fff; +} + +.summary-card-with-tooltip:hover .summary-tooltip { + opacity: 1; + visibility: visible; +} + +.tooltip-row { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.125rem 0; +} + +.tooltip-row span:first-child { + color: #64748b; +} + +.tooltip-row span:last-child { + font-weight: 600; + font-family: 'JetBrains Mono', monospace; + color: #1e2a3a; +} + /* Table */ .table-wrapper { overflow-x: auto; diff --git a/web/src/components/RidesTable/RidesTable.tsx b/web/src/components/RidesTable/RidesTable.tsx index ead8c96..bdfe958 100644 --- a/web/src/components/RidesTable/RidesTable.tsx +++ b/web/src/components/RidesTable/RidesTable.tsx @@ -297,6 +297,9 @@ interface Totals { totalEarnings: number; completedCount: number; cancelledCount: number; + cancelledByPassengerCount: number; + cancelledByDriverCount: number; + cancelledUncategorizedCount: number; } const getDateTimestamp = (date: { toDate: () => Date } | string | null | undefined): number => { @@ -330,15 +333,25 @@ const sortRidesByDateAndTime = (rides: FirestoreInDriverRide[]): FirestoreInDriv const calculateTotals = (rides: FirestoreInDriverRide[]): Totals => { return rides.reduce( - (acc, ride) => ({ - totalFare: acc.totalFare + (ride.base_fare || 0), - totalCommission: acc.totalCommission + (ride.service_commission || 0), - totalTax: acc.totalTax + (ride.service_tax || 0), - totalPaid: acc.totalPaid + (ride.total_paid || 0), - totalEarnings: acc.totalEarnings + (ride.net_earnings || 0), - completedCount: acc.completedCount + (ride.status === 'completed' ? 1 : 0), - cancelledCount: acc.cancelledCount + (ride.status !== 'completed' ? 1 : 0), - }), + (acc, ride) => { + const isCancelled = ride.status !== 'completed'; + const isCancelledByPassenger = ride.status === 'cancelled_by_passenger'; + const isCancelledByDriver = ride.status === 'cancelled_by_driver'; + const isUncategorized = isCancelled && !isCancelledByPassenger && !isCancelledByDriver; + + return { + totalFare: acc.totalFare + (ride.base_fare || 0), + totalCommission: acc.totalCommission + (ride.service_commission || 0), + totalTax: acc.totalTax + (ride.service_tax || 0), + totalPaid: acc.totalPaid + (ride.total_paid || 0), + totalEarnings: acc.totalEarnings + (ride.net_earnings || 0), + completedCount: acc.completedCount + (ride.status === 'completed' ? 1 : 0), + cancelledCount: acc.cancelledCount + (isCancelled ? 1 : 0), + cancelledByPassengerCount: acc.cancelledByPassengerCount + (isCancelledByPassenger ? 1 : 0), + cancelledByDriverCount: acc.cancelledByDriverCount + (isCancelledByDriver ? 1 : 0), + cancelledUncategorizedCount: acc.cancelledUncategorizedCount + (isUncategorized ? 1 : 0), + }; + }, { totalFare: 0, totalCommission: 0, @@ -347,6 +360,9 @@ const calculateTotals = (rides: FirestoreInDriverRide[]): Totals => { totalEarnings: 0, completedCount: 0, cancelledCount: 0, + cancelledByPassengerCount: 0, + cancelledByDriverCount: 0, + cancelledUncategorizedCount: 0, } ); }; @@ -444,6 +460,10 @@ export const RidesTable: FC = ({ if (statusFilter === 'all') return rides; if (statusFilter === 'completed') return rides.filter((r) => r.status === 'completed'); if (statusFilter === 'cancelled') return rides.filter((r) => r.status !== 'completed'); + if (statusFilter === 'cancelled_by_passenger') + return rides.filter((r) => r.status === 'cancelled_by_passenger'); + if (statusFilter === 'cancelled_by_driver') + return rides.filter((r) => r.status === 'cancelled_by_driver'); return rides; }, [rides, statusFilter]); @@ -558,9 +578,27 @@ export const RidesTable: FC = ({ Viajes Completados {allRidesTotals.completedCount}
-
+
Viajes Cancelados {allRidesTotals.cancelledCount} + {allRidesTotals.cancelledCount > 0 && ( +
+
+ Pasajero: + {allRidesTotals.cancelledByPassengerCount} +
+
+ Conductor: + {allRidesTotals.cancelledByDriverCount} +
+ {allRidesTotals.cancelledUncategorizedCount > 0 && ( +
+ Sin categoría: + {allRidesTotals.cancelledUncategorizedCount} +
+ )} +
+ )}
diff --git a/web/src/components/StatusFilter/StatusFilter.css b/web/src/components/StatusFilter/StatusFilter.css index 117e6b9..b9fea88 100644 --- a/web/src/components/StatusFilter/StatusFilter.css +++ b/web/src/components/StatusFilter/StatusFilter.css @@ -89,6 +89,7 @@ .status-filter-dropdown .dropdown-option { display: flex; align-items: center; + justify-content: flex-start; gap: 0.5rem; width: 100%; padding: 0.625rem 0.75rem; @@ -100,6 +101,7 @@ text-align: left; cursor: pointer; transition: background 0.1s ease; + white-space: nowrap; } .status-filter-dropdown .dropdown-option:hover { diff --git a/web/src/components/StatusFilter/StatusFilter.tsx b/web/src/components/StatusFilter/StatusFilter.tsx index caab142..1ec0078 100644 --- a/web/src/components/StatusFilter/StatusFilter.tsx +++ b/web/src/components/StatusFilter/StatusFilter.tsx @@ -6,7 +6,7 @@ import { type FC, useState, useRef, useEffect } from 'react'; import './StatusFilter.css'; -export type StatusFilterOption = 'all' | 'completed' | 'cancelled'; +export type StatusFilterOption = 'all' | 'completed' | 'cancelled' | 'cancelled_by_passenger' | 'cancelled_by_driver'; interface StatusFilterProps { value: StatusFilterOption; @@ -23,6 +23,8 @@ const filterOptions: FilterOptionConfig[] = [ { id: 'all', label: 'Todos los viajes', icon: '📋' }, { id: 'completed', label: 'Completados', icon: '✅' }, { id: 'cancelled', label: 'Cancelados', icon: '❌' }, + { id: 'cancelled_by_passenger', label: 'Cancelados (pasajero)', icon: '🚶' }, + { id: 'cancelled_by_driver', label: 'Cancelados (conductor)', icon: '🚗' }, ]; export const StatusFilter: FC = ({ value, onChange }) => { diff --git a/web/src/features/indriver-import/components/InDriverReviewTable.css b/web/src/features/indriver-import/components/InDriverReviewTable.css index 4511bdf..690d844 100644 --- a/web/src/features/indriver-import/components/InDriverReviewTable.css +++ b/web/src/features/indriver-import/components/InDriverReviewTable.css @@ -608,6 +608,34 @@ input[type='time'].editable-input::-webkit-calendar-picker-indicator:hover { font-size: 1rem; } +/* Warning styles for suspicious values (0 or very low) */ +.value-warning { + background-color: rgba(234, 179, 8, 0.15) !important; + border-radius: 4px; + animation: pulse-warning 2s ease-in-out infinite; +} + +.value-warning::before { + content: '⚠️'; + margin-right: 0.25rem; + font-size: 0.75rem; +} + +@keyframes pulse-warning { + 0%, + 100% { + background-color: rgba(234, 179, 8, 0.15); + } + 50% { + background-color: rgba(234, 179, 8, 0.3); + } +} + +.editable-cell.value-warning:hover { + background-color: rgba(234, 179, 8, 0.3) !important; + outline-color: var(--color-warning-500, #eab308); +} + /* Responsive */ @media (max-width: 768px) { .review-header { diff --git a/web/src/features/indriver-import/components/InDriverReviewTable.tsx b/web/src/features/indriver-import/components/InDriverReviewTable.tsx index e901dc8..6fbd558 100644 --- a/web/src/features/indriver-import/components/InDriverReviewTable.tsx +++ b/web/src/features/indriver-import/components/InDriverReviewTable.tsx @@ -21,6 +21,26 @@ import { } from '../utils/formatters'; import './InDriverReviewTable.css'; +// Minimum expected value in COP for monetary fields (anything below is suspicious) +const MIN_EXPECTED_VALUE_COP = 100; + +// Helper to detect suspicious values that need attention +const isSuspiciousValue = (value: number, fieldType: 'currency' | 'numeric' = 'currency'): boolean => { + if (value === 0) return true; + if (fieldType === 'currency' && value > 0 && value < MIN_EXPECTED_VALUE_COP) return true; + return false; +}; + +// Helper to detect missing/empty string values +const isMissingValue = (value: string | null | undefined): boolean => { + return !value || value.trim() === '' || value === '-'; +}; + +// Helper to detect missing numeric values (null, undefined, or 0) +const isMissingNumeric = (value: number | null | undefined): boolean => { + return value === null || value === undefined || value === 0; +}; + interface InDriverReviewTableProps { rides: ExtractedInDriverRide[]; summary: ExtractionSummary | null; @@ -473,6 +493,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'date')} onSave={(value) => handleUpdateField(ride, 'date', value)} onCancel={stopEditing} + className={isMissingValue(ride.date) ? 'value-warning' : ''} /> @@ -484,6 +505,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'time')} onSave={(value) => handleUpdateField(ride, 'time', value)} onCancel={stopEditing} + className={isMissingValue(ride.time) ? 'value-warning' : ''} /> @@ -495,6 +517,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'duration')} onSave={(value) => handleUpdateField(ride, 'duration', value)} onCancel={stopEditing} + className={isMissingNumeric(ride.duration?.value) ? 'value-warning' : ''} /> @@ -506,6 +529,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'distance')} onSave={(value) => handleUpdateField(ride, 'distance', value)} onCancel={stopEditing} + className={isMissingNumeric(ride.distance?.value) ? 'value-warning' : ''} /> @@ -521,7 +545,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'tarifa')} onSave={(value) => handleUpdateField(ride, 'tarifa', value)} onCancel={stopEditing} - className="income-value" + className={`income-value ${isSuspiciousValue(ride.tarifa) ? 'value-warning' : ''}`} /> {ride.payment_method_label || 'Efectivo'} @@ -536,7 +560,7 @@ export const InDriverReviewTable: FC = ({ onStartEdit={() => startEditing(ride.id, 'total_pagado')} onSave={(value) => handleUpdateField(ride, 'total_pagado', value)} onCancel={stopEditing} - className="deduction-value" + className={`deduction-value ${isSuspiciousValue(ride.total_pagado) ? 'value-warning' : ''}`} /> = ({ onStartEdit={() => startEditing(ride.id, 'comision_servicio')} onSave={(value) => handleUpdateField(ride, 'comision_servicio', value)} onCancel={stopEditing} - className="deduction-detail" + className={`deduction-detail ${isSuspiciousValue(ride.comision_servicio) ? 'value-warning' : ''}`} /> = ({ onStartEdit={() => startEditing(ride.id, 'mis_ingresos')} onSave={(value) => handleUpdateField(ride, 'mis_ingresos', value)} onCancel={stopEditing} - className="net-value" + className={`net-value ${isSuspiciousValue(ride.mis_ingresos) ? 'value-warning' : ''}`} /> diff --git a/web/src/pages/DashboardPage.css b/web/src/pages/DashboardPage.css index 4c5f811..1eca413 100644 --- a/web/src/pages/DashboardPage.css +++ b/web/src/pages/DashboardPage.css @@ -69,17 +69,23 @@ background: var(--color-accent-500, #f47585); } -.btn-outline { +.btn.btn-outline { background: #fff; color: var(--color-primary-800, #1e2a3a); - border: 1px solid #e2e8f0; + border: 1px solid #e2e8f0 !important; height: auto; line-height: 1.5; } -.btn-outline:hover { +.btn.btn-outline:hover { background: #f8fafc; - border-color: #cbd5e1; + border-color: #cbd5e1 !important; +} + +.btn.btn-outline:focus { + outline: none; + border-color: var(--color-accent-600, #f05365) !important; + box-shadow: 0 0 0 3px rgba(240, 83, 101, 0.1); } /* Error Banner */ From 8ef5e76125542e40d75658341f2dbec95250edd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cstevenmunoz=E2=80=9D?= <“steven.munoz90@gmail.com”> Date: Thu, 11 Dec 2025 22:35:05 -0500 Subject: [PATCH 04/31] feat: add vehicle management with image upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add VehiclesPage with CRUD operations for driver vehicles - Add VehicleForm component with validation (plate, brand, model, year, etc.) - Add VehiclesTable component with photo thumbnails and action buttons - Implement Firebase Storage for vehicle image uploads with compression - Add storage.rules for secure image access (driver-scoped) - Update CI/CD to deploy storage rules alongside Firestore rules - Add useDriverVehicles hook for vehicle state management - Fix Spanish accent marks (Vehículos, Año, etc.) - Fix date picker calendar icon visibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .conductor-workspace | 8 +- .github/workflows/deploy-web.yml | 11 + web/firebase.json | 6 +- web/firestore.indexes.json | 13 + web/firestore.rules | 5 + .../DashboardLayout/DashboardLayout.tsx | 1 + .../components/VehicleForm/VehicleForm.css | 367 +++++++++++++++ .../components/VehicleForm/VehicleForm.tsx | 427 ++++++++++++++++++ web/src/components/VehicleForm/index.ts | 1 + .../VehiclesTable/VehiclesTable.css | 386 ++++++++++++++++ .../VehiclesTable/VehiclesTable.tsx | 237 ++++++++++ web/src/components/VehiclesTable/index.ts | 1 + web/src/core/firebase/config.ts | 4 + web/src/core/firebase/index.ts | 16 +- web/src/core/firebase/storage.ts | 170 +++++++ web/src/core/firebase/vehicles.ts | 229 ++++++++++ web/src/core/types/index.ts | 2 + web/src/core/types/vehicle.types.ts | 73 +++ web/src/hooks/useDriverVehicles.ts | 194 ++++++++ web/src/pages/VehiclesPage.css | 183 ++++++++ web/src/pages/VehiclesPage.tsx | 179 ++++++++ web/src/routes/index.tsx | 5 + web/storage.rules | 22 + 23 files changed, 2534 insertions(+), 6 deletions(-) create mode 100644 web/firestore.indexes.json create mode 100644 web/src/components/VehicleForm/VehicleForm.css create mode 100644 web/src/components/VehicleForm/VehicleForm.tsx create mode 100644 web/src/components/VehicleForm/index.ts create mode 100644 web/src/components/VehiclesTable/VehiclesTable.css create mode 100644 web/src/components/VehiclesTable/VehiclesTable.tsx create mode 100644 web/src/components/VehiclesTable/index.ts create mode 100644 web/src/core/firebase/storage.ts create mode 100644 web/src/core/firebase/vehicles.ts create mode 100644 web/src/core/types/vehicle.types.ts create mode 100644 web/src/hooks/useDriverVehicles.ts create mode 100644 web/src/pages/VehiclesPage.css create mode 100644 web/src/pages/VehiclesPage.tsx create mode 100644 web/storage.rules diff --git a/.conductor-workspace b/.conductor-workspace index 68dd147..20c955d 100644 --- a/.conductor-workspace +++ b/.conductor-workspace @@ -1,8 +1,8 @@ # Conductor Workspace Configuration -WORKSPACE_NAME=ottawa -WORKSPACE_PATH=/Users/stevenmunoz/conductor/wego/.conductor/ottawa -PROJECT_ROOT=/Users/stevenmunoz/conductor/wego/.conductor/ottawa -SETUP_DATE=2025-12-11T23:39:51Z +WORKSPACE_NAME=copenhagen +WORKSPACE_PATH=/Users/stevenmunoz/conductor/wego/.conductor/copenhagen +PROJECT_ROOT=/Users/stevenmunoz/conductor/wego/.conductor/copenhagen +SETUP_DATE=2025-12-12T02:13:40Z ROOT_PATH=/Users/stevenmunoz/conductor/wego PYTHON_VERSION=3.11 NODE_VERSION=21.2 diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 75f7d40..c68be90 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -6,6 +6,7 @@ on: paths: - 'web/**' - '.github/workflows/deploy-web.yml' + workflow_dispatch: # Allow manual trigger jobs: deploy: @@ -105,6 +106,16 @@ jobs: working-directory: ./web run: npm run build + - name: Deploy Firebase Rules and Indexes + working-directory: ./web + env: + GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ steps.env.outputs.environment == 'production' && secrets.FIREBASE_SERVICE_ACCOUNT_PROD || secrets.FIREBASE_SERVICE_ACCOUNT_DEV }} + run: | + echo "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > /tmp/firebase-key.json + export GOOGLE_APPLICATION_CREDENTIALS=/tmp/firebase-key.json + npx firebase-tools deploy --only firestore:rules,firestore:indexes,storage --project ${{ steps.env.outputs.firebase_project_id }} --non-interactive + rm /tmp/firebase-key.json + - name: Deploy to Firebase Hosting uses: FirebaseExtended/action-hosting-deploy@v0 with: diff --git a/web/firebase.json b/web/firebase.json index 185309f..6eff1a5 100644 --- a/web/firebase.json +++ b/web/firebase.json @@ -14,6 +14,10 @@ ] }, "firestore": { - "rules": "firestore.rules" + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "storage": { + "rules": "storage.rules" } } diff --git a/web/firestore.indexes.json b/web/firestore.indexes.json new file mode 100644 index 0000000..012b3f2 --- /dev/null +++ b/web/firestore.indexes.json @@ -0,0 +1,13 @@ +{ + "indexes": [ + { + "collectionGroup": "vehicles", + "queryScope": "COLLECTION", + "fields": [ + { "fieldPath": "is_primary", "order": "DESCENDING" }, + { "fieldPath": "created_at", "order": "DESCENDING" } + ] + } + ], + "fieldOverrides": [] +} diff --git a/web/firestore.rules b/web/firestore.rules index 17a5071..5ac7274 100644 --- a/web/firestore.rules +++ b/web/firestore.rules @@ -10,6 +10,11 @@ service cloud.firestore { match /driver_rides/{rideId} { allow read, write: if request.auth != null && request.auth.uid == driverId; } + + // Driver vehicles subcollection + match /vehicles/{vehicleId} { + allow read, write: if request.auth != null && request.auth.uid == driverId; + } } // Users collection - users can read/write their own profile diff --git a/web/src/components/DashboardLayout/DashboardLayout.tsx b/web/src/components/DashboardLayout/DashboardLayout.tsx index 775a59b..08f3621 100644 --- a/web/src/components/DashboardLayout/DashboardLayout.tsx +++ b/web/src/components/DashboardLayout/DashboardLayout.tsx @@ -19,6 +19,7 @@ interface NavItem { const navItems: NavItem[] = [ { path: '/dashboard', label: 'Mis Viajes', icon: '🚗' }, + { path: '/vehicles', label: 'Mis Vehículos', icon: '🚙' }, { path: '/indriver-import', label: 'Importar Viajes', icon: '📸' }, ]; diff --git a/web/src/components/VehicleForm/VehicleForm.css b/web/src/components/VehicleForm/VehicleForm.css new file mode 100644 index 0000000..ece79a6 --- /dev/null +++ b/web/src/components/VehicleForm/VehicleForm.css @@ -0,0 +1,367 @@ +/** + * Vehicle Form Styles + */ + +.vehicle-form-container { + background: #fff; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15); + max-width: 720px; + width: 100%; + max-height: 90vh; + overflow-y: auto; +} + +.form-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 1px solid #e2e8f0; + position: sticky; + top: 0; + background: #fff; + z-index: 1; +} + +.form-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-primary-800, #1e2a3a); +} + +.btn-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 6px; + font-size: 1.5rem; + color: #64748b; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn-close:hover { + background: #f1f5f9; + color: var(--color-primary-800, #1e2a3a); +} + +.vehicle-form { + padding: 1.5rem; +} + +/* Form Sections */ +.form-section { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem 1.25rem; + margin-bottom: 1.5rem; +} + +.form-section legend { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-primary-800, #1e2a3a); + padding: 0 0.5rem; + margin-left: -0.5rem; +} + +/* Form Row */ +.form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.form-row:last-child { + margin-bottom: 0; +} + +@media (max-width: 480px) { + .form-row { + grid-template-columns: 1fr; + } +} + +/* Form Group */ +.form-group { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: #475569; +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 0.625rem 0.875rem; + border: 1px solid #e2e8f0; + border-radius: 6px; + font-size: 0.9375rem; + color: var(--color-primary-800, #1e2a3a); + background: #fff; + transition: all 0.15s ease; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-accent-600, #f05365); + box-shadow: 0 0 0 3px rgba(240, 83, 101, 0.1); +} + +.form-group input.error, +.form-group select.error { + border-color: var(--color-error-500, #ef4444); +} + +.form-group input.error:focus, +.form-group select.error:focus { + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); +} + +.form-group input::placeholder, +.form-group textarea::placeholder { + color: #94a3b8; +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +/* Error Message */ +.error-message { + font-size: 0.75rem; + color: var(--color-error-500, #ef4444); +} + +/* Checkbox Group */ +.checkbox-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 0.9375rem; + color: #475569; + cursor: pointer; + line-height: 1.4; +} + +.checkbox-label input[type='checkbox'] { + flex-shrink: 0; + width: 18px; + height: 18px; + accent-color: var(--color-accent-600, #f05365); + cursor: pointer; + margin: 0; +} + +/* Form Actions */ +.form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; + margin-top: 1rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-accent-600, #f05365); + color: #fff; +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-accent-500, #f47585); +} + +.btn-secondary { + background: #f1f5f9; + color: #475569; + border: 1px solid #e2e8f0; +} + +.btn-secondary:hover:not(:disabled) { + background: #e2e8f0; +} + +/* Select Styling */ +.form-group select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8L2 4h8z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 0.875rem center; + padding-right: 2.5rem; + cursor: pointer; +} + +/* Date Input Styling */ +.form-group input[type='date'] { + cursor: pointer; + position: relative; +} + +.form-group input[type='date']::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 1; + display: block; + background: url('data:image/svg+xml,') no-repeat center; + background-size: 16px 16px; + width: 20px; + height: 20px; + padding: 0; + margin-right: 4px; +} + +.form-group input[type='date']::-webkit-calendar-picker-indicator:hover { + opacity: 0.8; +} + +/* Number Input Styling */ +.form-group input[type='number'] { + font-family: 'JetBrains Mono', monospace; +} + +.form-group input[type='number']::-webkit-inner-spin-button, +.form-group input[type='number']::-webkit-outer-spin-button { + opacity: 1; +} + +/* Image Upload Styles */ +.image-upload-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.image-input { + display: none; +} + +.image-upload-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 180px; + border: 2px dashed #e2e8f0; + border-radius: 8px; + background: #f8fafc; + cursor: pointer; + transition: all 0.15s ease; +} + +.image-upload-placeholder:hover { + border-color: var(--color-accent-600, #f05365); + background: #fff; +} + +.upload-icon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.upload-text { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-primary-800, #1e2a3a); +} + +.upload-hint { + font-size: 0.75rem; + color: #94a3b8; + margin-top: 0.25rem; +} + +.image-preview-wrapper { + position: relative; + display: inline-block; +} + +.image-preview { + max-width: 100%; + max-height: 200px; + border-radius: 8px; + object-fit: cover; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.btn-remove-image { + position: absolute; + top: -8px; + right: -8px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-error-500, #ef4444); + color: #fff; + border: 2px solid #fff; + border-radius: 50%; + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.btn-remove-image:hover { + background: #dc2626; + transform: scale(1.1); +} + +.btn-change-image { + padding: 0.5rem 1rem; + font-size: 0.8125rem; +} + +.btn-outline { + background: transparent; + border: 1px solid #e2e8f0; + color: #475569; +} + +.btn-outline:hover { + background: #f1f5f9; + border-color: #cbd5e1; +} diff --git a/web/src/components/VehicleForm/VehicleForm.tsx b/web/src/components/VehicleForm/VehicleForm.tsx new file mode 100644 index 0000000..e149607 --- /dev/null +++ b/web/src/components/VehicleForm/VehicleForm.tsx @@ -0,0 +1,427 @@ +/** + * Vehicle Form Component + * + * Form for adding/editing vehicles with validation + */ + +import { type FC, useState, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import type { VehicleCreateInput } from '@/core/types'; +import type { FirestoreVehicle } from '@/core/firebase'; +import './VehicleForm.css'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +const currentYear = new Date().getFullYear(); + +const vehicleSchema = z.object({ + plate: z + .string() + .min(6, 'La placa debe tener al menos 6 caracteres') + .regex(/^[A-Za-z]{3}-?\d{3}$/, 'Formato de placa invalido (ej: ABC123 o ABC-123)'), + brand: z.string().min(2, 'La marca es requerida'), + model: z.string().min(1, 'El modelo es requerido'), + year: z.coerce + .number() + .min(1990, 'El año debe ser mayor a 1990') + .max(currentYear + 1, 'Año inválido'), + color: z.string().min(2, 'El color es requerido'), + vehicle_type: z.enum(['car', 'suv', 'van', 'motorcycle']), + fuel_type: z.enum(['gasoline', 'diesel', 'electric', 'hybrid', 'gas']), + passenger_capacity: z.coerce.number().min(1, 'Mínimo 1 pasajero').max(15, 'Máximo 15 pasajeros'), + luggage_capacity: z.coerce.number().min(0).max(10).optional(), + accepts_pets: z.boolean().optional(), + accepts_wheelchairs: z.boolean().optional(), + has_child_seat: z.boolean().optional(), + has_air_conditioning: z.boolean().optional(), + soat_expiry: z.string().optional(), + tecnomecanica_expiry: z.string().optional(), + is_primary: z.boolean().optional(), + notes: z.string().optional(), +}); + +type VehicleFormData = z.infer; + +interface VehicleFormProps { + vehicle?: FirestoreVehicle; + onSubmit: (data: VehicleCreateInput) => Promise; + onCancel: () => void; + isSubmitting?: boolean; +} + +// Convert Firestore Timestamp to date string for input +const timestampToDateString = (timestamp: { toDate: () => Date } | null): string => { + if (!timestamp) return ''; + const date = timestamp.toDate(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +export const VehicleForm: FC = ({ + vehicle, + onSubmit, + onCancel, + isSubmitting = false, +}) => { + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(vehicle?.photo_url || null); + const [imageError, setImageError] = useState(null); + const fileInputRef = useRef(null); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(vehicleSchema), + defaultValues: vehicle + ? { + plate: vehicle.plate, + brand: vehicle.brand, + model: vehicle.model, + year: vehicle.year, + color: vehicle.color, + vehicle_type: vehicle.vehicle_type, + fuel_type: vehicle.fuel_type, + passenger_capacity: vehicle.passenger_capacity, + luggage_capacity: vehicle.luggage_capacity, + accepts_pets: vehicle.accepts_pets, + accepts_wheelchairs: vehicle.accepts_wheelchairs, + has_child_seat: vehicle.has_child_seat, + has_air_conditioning: vehicle.has_air_conditioning, + soat_expiry: timestampToDateString(vehicle.soat_expiry), + tecnomecanica_expiry: timestampToDateString(vehicle.tecnomecanica_expiry), + is_primary: vehicle.is_primary, + notes: vehicle.notes, + } + : { + vehicle_type: 'car', + fuel_type: 'gasoline', + passenger_capacity: 4, + luggage_capacity: 2, + accepts_pets: false, + accepts_wheelchairs: false, + has_child_seat: false, + has_air_conditioning: true, + is_primary: false, + }, + }); + + const handleImageSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + setImageError(null); + + if (!file) { + return; + } + + // Validate file type + if (!ALLOWED_TYPES.includes(file.type)) { + setImageError('Tipo de archivo no permitido. Use JPG, PNG o WebP.'); + return; + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + setImageError('El archivo es muy grande. Máximo 5MB.'); + return; + } + + setImageFile(file); + + // Create preview + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result as string); + }; + reader.readAsDataURL(file); + }; + + const handleRemoveImage = () => { + setImageFile(null); + setImagePreview(vehicle?.photo_url || null); + setImageError(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleFormSubmit = async (data: VehicleFormData) => { + const submitData: VehicleCreateInput = { + ...data, + imageFile: imageFile || undefined, + }; + await onSubmit(submitData); + }; + + return ( +
+
+

{vehicle ? 'Editar Vehículo' : 'Agregar Vehículo'}

+ +
+ +
+ {/* Vehicle Photo */} +
+ Foto del Vehículo + +
+ {imagePreview ? ( +
+ Vista previa del vehículo + +
+ ) : ( +
fileInputRef.current?.click()} + > + 📷 + Haz clic para subir una foto + JPG, PNG o WebP (máx. 5MB) +
+ )} + + + + {imagePreview && ( + + )} + + {imageError && {imageError}} +
+
+ + {/* Vehicle Identification */} +
+ Identificación del Vehículo + +
+
+ + + {errors.plate && {errors.plate.message}} +
+ +
+ + + {errors.year && {errors.year.message}} +
+
+ +
+
+ + + {errors.brand && {errors.brand.message}} +
+ +
+ + + {errors.model && {errors.model.message}} +
+
+ +
+
+ + + {errors.color && {errors.color.message}} +
+
+
+ + {/* Vehicle Classification */} +
+ Clasificación + +
+
+ + +
+ +
+ + +
+
+ +
+
+ + + {errors.passenger_capacity && ( + {errors.passenger_capacity.message} + )} +
+ +
+ + +
+
+
+ + {/* Capabilities */} +
+ Capacidades + +
+ + + + + + + + + +
+
+ + {/* Documents */} +
+ Documentos + +
+
+ + +
+ +
+ + +
+
+
+ + {/* Notes */} +
+ Notas + +
+