fix the upload issue and add driver license upload
This commit is contained in:
@@ -7,6 +7,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
|
apps/api/storage/
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ Notes:
|
|||||||
|
|
||||||
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
On startup, Docker now waits for Postgres to become healthy, runs a one-shot `migrate` service, and only then starts the selected app container. For development, that bootstrap runs `db:generate` every time, but `db:deploy` and `db:seed` only the first time for a persisted dev database, so your local data survives rebuilds and normal restarts.
|
||||||
|
|
||||||
|
Uploaded assets handled by the API, including vehicle photos and company branding images, are stored in the named Docker volume `api_uploads_dev` at `/var/lib/rentaldrivego/storage` inside the container. They stay available across normal container rebuilds and restarts.
|
||||||
|
|
||||||
Default dev platform administrator:
|
Default dev platform administrator:
|
||||||
|
|
||||||
- email: `admin@rentaldrivego.com`
|
- email: `admin@rentaldrivego.com`
|
||||||
@@ -188,6 +190,8 @@ Docker will:
|
|||||||
|
|
||||||
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
Traefik automatically picks up the containers and provisions TLS certificates. Services are live at their `https://` URLs within ~30 seconds.
|
||||||
|
|
||||||
|
Uploaded assets handled by the API, including vehicle photos and company branding images, are persisted in the named Docker volume `api_uploads`, mounted inside the API container at `/var/lib/rentaldrivego/storage`. Rebuilding or redeploying the API no longer clears those files as long as that volume is kept.
|
||||||
|
|
||||||
#### Updating after a code change
|
#### Updating after a code change
|
||||||
|
|
||||||
Pull the latest code and rebuild only the changed service:
|
Pull the latest code and rebuild only the changed service:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import jwt from 'jsonwebtoken'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { redis } from './lib/redis'
|
import { redis } from './lib/redis'
|
||||||
import { prisma } from './lib/prisma'
|
import { prisma } from './lib/prisma'
|
||||||
|
import { getStorageRoot } from './lib/storage'
|
||||||
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
import { authLimiter, apiLimiter, publicLimiter, adminLimiter } from './middleware/rateLimiter'
|
||||||
|
|
||||||
// ─── Routes ───────────────────────────────────────────────────
|
// ─── Routes ───────────────────────────────────────────────────
|
||||||
@@ -122,8 +123,8 @@ subscriber.on('pmessage', (_pattern, channel, message) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ─── Middleware ────────────────────────────────────────────────
|
// ─── Middleware ────────────────────────────────────────────────
|
||||||
// Serve local file storage
|
// Serve uploaded assets from the configured storage root.
|
||||||
app.use('/storage', express.static(require('path').resolve(__dirname, 'lib/storage')))
|
app.use('/storage', express.static(getStorageRoot()))
|
||||||
|
|
||||||
// Webhook must use raw body BEFORE express.json()
|
// Webhook must use raw body BEFORE express.json()
|
||||||
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
app.use('/api/v1/webhooks', express.raw({ type: 'application/json' }), webhookRouter)
|
||||||
|
|||||||
@@ -2,7 +2,19 @@ import fs from 'fs'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
|
||||||
const STORAGE_ROOT = path.resolve(__dirname, 'storage')
|
const DEFAULT_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'storage')
|
||||||
|
|
||||||
|
export function getStorageRoot(): string {
|
||||||
|
return process.env.FILE_STORAGE_ROOT
|
||||||
|
? path.resolve(process.env.FILE_STORAGE_ROOT)
|
||||||
|
: DEFAULT_STORAGE_ROOT
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStorageRoot(): string {
|
||||||
|
const storageRoot = getStorageRoot()
|
||||||
|
fs.mkdirSync(storageRoot, { recursive: true })
|
||||||
|
return storageRoot
|
||||||
|
}
|
||||||
|
|
||||||
function getApiBase(): string {
|
function getApiBase(): string {
|
||||||
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
return (process.env.API_URL ?? 'http://localhost:4000').replace(/\/$/, '')
|
||||||
@@ -13,7 +25,8 @@ export async function uploadImage(
|
|||||||
folder: string,
|
folder: string,
|
||||||
publicId?: string
|
publicId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const folderPath = path.join(STORAGE_ROOT, folder)
|
const storageRoot = ensureStorageRoot()
|
||||||
|
const folderPath = path.join(storageRoot, folder)
|
||||||
fs.mkdirSync(folderPath, { recursive: true })
|
fs.mkdirSync(folderPath, { recursive: true })
|
||||||
|
|
||||||
const filename = publicId
|
const filename = publicId
|
||||||
@@ -26,10 +39,11 @@ export async function uploadImage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteImage(imageUrl: string): Promise<void> {
|
export async function deleteImage(imageUrl: string): Promise<void> {
|
||||||
|
const storageRoot = getStorageRoot()
|
||||||
const marker = '/storage/'
|
const marker = '/storage/'
|
||||||
const idx = imageUrl.indexOf(marker)
|
const idx = imageUrl.indexOf(marker)
|
||||||
if (idx === -1) return
|
if (idx === -1) return
|
||||||
const relative = imageUrl.slice(idx + marker.length)
|
const relative = imageUrl.slice(idx + marker.length)
|
||||||
const filePath = path.join(STORAGE_ROOT, relative)
|
const filePath = path.join(storageRoot, relative)
|
||||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Router } from 'express'
|
import { Router } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import multer from 'multer'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
|
import { deleteImage, uploadImage } from '../lib/storage'
|
||||||
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
|
||||||
import { requireTenant } from '../middleware/requireTenant'
|
import { requireTenant } from '../middleware/requireTenant'
|
||||||
import { requireSubscription } from '../middleware/requireSubscription'
|
import { requireSubscription } from '../middleware/requireSubscription'
|
||||||
@@ -8,6 +10,7 @@ import { requireRole } from '../middleware/requireRole'
|
|||||||
import { validateAndFlagLicense } from '../services/licenseValidationService'
|
import { validateAndFlagLicense } from '../services/licenseValidationService'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
|
||||||
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
router.use(requireCompanyAuth, requireTenant, requireSubscription)
|
||||||
|
|
||||||
const paginationSchema = z.object({
|
const paginationSchema = z.object({
|
||||||
@@ -113,6 +116,31 @@ router.post('/:id/validate-license', async (req, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.post('/:id/license-image', upload.single('file'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'missing_file', message: 'A license image file is required', statusCode: 400 })
|
||||||
|
}
|
||||||
|
if (!req.file.mimetype.startsWith('image/')) {
|
||||||
|
return res.status(400).json({ error: 'invalid_file_type', message: 'Only image uploads are supported', statusCode: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await prisma.customer.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
|
||||||
|
const url = await uploadImage(req.file.buffer, `companies/${req.companyId}/customers/${customer.id}`)
|
||||||
|
|
||||||
|
if (customer.licenseImageUrl) {
|
||||||
|
await deleteImage(customer.licenseImageUrl).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.customer.update({
|
||||||
|
where: { id: customer.id },
|
||||||
|
data: { licenseImageUrl: url },
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ data: updated })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
|
router.post('/:id/approve-license', requireRole('MANAGER'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body)
|
const { decision, note } = z.object({ decision: z.enum(['APPROVE', 'DENY']), note: z.string().optional() }).parse(req.body)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface CustomerRow {
|
|||||||
phone: string | null
|
phone: string | null
|
||||||
flagged: boolean
|
flagged: boolean
|
||||||
licenseValidationStatus: string
|
licenseValidationStatus: string
|
||||||
|
licenseImageUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CustomersPage() {
|
export default function CustomersPage() {
|
||||||
@@ -26,6 +27,8 @@ export default function CustomersPage() {
|
|||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
license: 'License',
|
license: 'License',
|
||||||
flags: 'Flags',
|
flags: 'Flags',
|
||||||
|
imageUploaded: 'Image uploaded',
|
||||||
|
imageMissing: 'No image',
|
||||||
noPhone: 'No phone',
|
noPhone: 'No phone',
|
||||||
flagged: 'Flagged',
|
flagged: 'Flagged',
|
||||||
clear: 'Clear',
|
clear: 'Clear',
|
||||||
@@ -38,6 +41,8 @@ export default function CustomersPage() {
|
|||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
license: 'Permis',
|
license: 'Permis',
|
||||||
flags: 'Signalements',
|
flags: 'Signalements',
|
||||||
|
imageUploaded: 'Image téléversée',
|
||||||
|
imageMissing: 'Pas d’image',
|
||||||
noPhone: 'Pas de téléphone',
|
noPhone: 'Pas de téléphone',
|
||||||
flagged: 'Signalé',
|
flagged: 'Signalé',
|
||||||
clear: 'Aucun risque',
|
clear: 'Aucun risque',
|
||||||
@@ -50,6 +55,8 @@ export default function CustomersPage() {
|
|||||||
contact: 'التواصل',
|
contact: 'التواصل',
|
||||||
license: 'الرخصة',
|
license: 'الرخصة',
|
||||||
flags: 'الإشارات',
|
flags: 'الإشارات',
|
||||||
|
imageUploaded: 'تم رفع الصورة',
|
||||||
|
imageMissing: 'لا توجد صورة',
|
||||||
noPhone: 'لا يوجد هاتف',
|
noPhone: 'لا يوجد هاتف',
|
||||||
flagged: 'مُعلّم',
|
flagged: 'مُعلّم',
|
||||||
clear: 'سليم',
|
clear: 'سليم',
|
||||||
@@ -91,7 +98,14 @@ export default function CustomersPage() {
|
|||||||
<p className="text-sm text-slate-700">{row.email}</p>
|
<p className="text-sm text-slate-700">{row.email}</p>
|
||||||
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
|
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4"><span className="badge-gray">{row.licenseValidationStatus}</span></td>
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="badge-gray">{row.licenseValidationStatus}</span>
|
||||||
|
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
|
||||||
|
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
|
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface ReservationDetail {
|
|||||||
email: string
|
email: string
|
||||||
phone: string | null
|
phone: string | null
|
||||||
driverLicense: string | null
|
driverLicense: string | null
|
||||||
|
licenseImageUrl: string | null
|
||||||
licenseValidationStatus: string
|
licenseValidationStatus: string
|
||||||
flagged: boolean
|
flagged: boolean
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,8 @@ const detailCopy = {
|
|||||||
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
|
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
|
||||||
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
|
||||||
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
|
||||||
|
licenseImageLabel: 'License image',
|
||||||
|
noLicenseImage: 'No license image uploaded.',
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
editBooking: 'Modifier la réservation',
|
editBooking: 'Modifier la réservation',
|
||||||
@@ -150,6 +153,8 @@ const detailCopy = {
|
|||||||
checkInReadOnly: 'L’inspection de départ est modifiable après confirmation et avant le retour.',
|
checkInReadOnly: 'L’inspection de départ est modifiable après confirmation et avant le retour.',
|
||||||
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.',
|
||||||
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.',
|
||||||
|
licenseImageLabel: 'Image du permis',
|
||||||
|
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
editBooking: 'تعديل الحجز',
|
editBooking: 'تعديل الحجز',
|
||||||
@@ -185,6 +190,8 @@ const detailCopy = {
|
|||||||
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
|
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
|
||||||
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
|
||||||
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
|
||||||
|
licenseImageLabel: 'صورة الرخصة',
|
||||||
|
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -436,6 +443,20 @@ export default function ReservationDetailPage() {
|
|||||||
<p className="text-slate-600">{reservation.customer.email}</p>
|
<p className="text-slate-600">{reservation.customer.email}</p>
|
||||||
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
|
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
|
||||||
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
|
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
|
||||||
|
<div className="pt-2">
|
||||||
|
<p className="text-slate-600">{copy.licenseImageLabel}</p>
|
||||||
|
{reservation.customer.licenseImageUrl ? (
|
||||||
|
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
|
||||||
|
<img
|
||||||
|
src={reservation.customer.licenseImageUrl}
|
||||||
|
alt="Driver license"
|
||||||
|
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 pt-2">
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
|
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
|
||||||
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
|
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ type Customer = {
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
email: string
|
email: string
|
||||||
|
phone?: string | null
|
||||||
driverLicense?: string | null
|
driverLicense?: string | null
|
||||||
licenseExpiry?: string | null
|
licenseExpiry?: string | null
|
||||||
licenseIssuedAt?: string | null
|
licenseIssuedAt?: string | null
|
||||||
licenseCountry?: string | null
|
licenseCountry?: string | null
|
||||||
licenseCategory?: string | null
|
licenseCategory?: string | null
|
||||||
|
licenseImageUrl?: string | null
|
||||||
}
|
}
|
||||||
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
|
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
|
||||||
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
|
|
||||||
type AdditionalDriverForm = {
|
type AdditionalDriverForm = {
|
||||||
firstName: BilingualField
|
firstName: BilingualField
|
||||||
lastName: BilingualField
|
lastName: BilingualField
|
||||||
@@ -65,6 +66,9 @@ export default function NewReservationPage() {
|
|||||||
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
||||||
const [licenseCountry, setLicenseCountry] = useState('')
|
const [licenseCountry, setLicenseCountry] = useState('')
|
||||||
const [licenseCategory, setLicenseCategory] = useState('')
|
const [licenseCategory, setLicenseCategory] = useState('')
|
||||||
|
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
|
||||||
|
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
|
||||||
|
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
|
||||||
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
|
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
|
||||||
firstName: emptyBilingual(),
|
firstName: emptyBilingual(),
|
||||||
lastName: emptyBilingual(),
|
lastName: emptyBilingual(),
|
||||||
@@ -96,6 +100,11 @@ export default function NewReservationPage() {
|
|||||||
licenseIssuedAt: 'License issued at',
|
licenseIssuedAt: 'License issued at',
|
||||||
licenseCountry: 'License country',
|
licenseCountry: 'License country',
|
||||||
licenseCategory: 'License category',
|
licenseCategory: 'License category',
|
||||||
|
licenseImage: 'Driver license image',
|
||||||
|
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
|
||||||
|
licenseImageSelected: 'Selected file',
|
||||||
|
licenseImageCurrent: 'Current image',
|
||||||
|
noLicenseImage: 'No license image uploaded yet.',
|
||||||
addAdditionalDriver: 'Add additional driver',
|
addAdditionalDriver: 'Add additional driver',
|
||||||
additionalDriverInfo: 'Additional driver',
|
additionalDriverInfo: 'Additional driver',
|
||||||
dateOfBirth: 'Date of birth',
|
dateOfBirth: 'Date of birth',
|
||||||
@@ -145,6 +154,11 @@ export default function NewReservationPage() {
|
|||||||
licenseIssuedAt: 'Permis délivré le',
|
licenseIssuedAt: 'Permis délivré le',
|
||||||
licenseCountry: 'Pays du permis',
|
licenseCountry: 'Pays du permis',
|
||||||
licenseCategory: 'Catégorie du permis',
|
licenseCategory: 'Catégorie du permis',
|
||||||
|
licenseImage: 'Image du permis',
|
||||||
|
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
|
||||||
|
licenseImageSelected: 'Fichier sélectionné',
|
||||||
|
licenseImageCurrent: 'Image actuelle',
|
||||||
|
noLicenseImage: 'Aucune image de permis téléversée.',
|
||||||
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
||||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
additionalDriverInfo: 'Conducteur supplémentaire',
|
||||||
dateOfBirth: 'Date de naissance',
|
dateOfBirth: 'Date de naissance',
|
||||||
@@ -194,6 +208,11 @@ export default function NewReservationPage() {
|
|||||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
||||||
licenseCountry: 'بلد الرخصة',
|
licenseCountry: 'بلد الرخصة',
|
||||||
licenseCategory: 'فئة الرخصة',
|
licenseCategory: 'فئة الرخصة',
|
||||||
|
licenseImage: 'صورة رخصة القيادة',
|
||||||
|
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
||||||
|
licenseImageSelected: 'الملف المحدد',
|
||||||
|
licenseImageCurrent: 'الصورة الحالية',
|
||||||
|
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
|
||||||
addAdditionalDriver: 'إضافة سائق إضافي',
|
addAdditionalDriver: 'إضافة سائق إضافي',
|
||||||
additionalDriverInfo: 'السائق الإضافي',
|
additionalDriverInfo: 'السائق الإضافي',
|
||||||
dateOfBirth: 'تاريخ الميلاد',
|
dateOfBirth: 'تاريخ الميلاد',
|
||||||
@@ -261,8 +280,38 @@ export default function NewReservationPage() {
|
|||||||
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
||||||
setLicenseCountry(selected.licenseCountry ?? '')
|
setLicenseCountry(selected.licenseCountry ?? '')
|
||||||
setLicenseCategory(selected.licenseCategory ?? '')
|
setLicenseCategory(selected.licenseCategory ?? '')
|
||||||
|
setLicenseImageUrl(selected.licenseImageUrl ?? null)
|
||||||
|
setLicenseImageFile(null)
|
||||||
}, [customerId, customers])
|
}, [customerId, customers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!licenseImageFile) {
|
||||||
|
setLicenseImagePreviewUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(licenseImageFile)
|
||||||
|
setLicenseImagePreviewUrl(objectUrl)
|
||||||
|
return () => URL.revokeObjectURL(objectUrl)
|
||||||
|
}, [licenseImageFile])
|
||||||
|
|
||||||
|
async function uploadLicenseImage(customerIdToUpdate: string) {
|
||||||
|
if (!licenseImageFile) return null
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', licenseImageFile)
|
||||||
|
|
||||||
|
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
|
||||||
|
setLicenseImageUrl(updated.licenseImageUrl ?? null)
|
||||||
|
setLicenseImageFile(null)
|
||||||
|
return updated.licenseImageUrl ?? null
|
||||||
|
}
|
||||||
|
|
||||||
async function addCustomer() {
|
async function addCustomer() {
|
||||||
setError(null)
|
setError(null)
|
||||||
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
|
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
|
||||||
@@ -271,7 +320,7 @@ export default function NewReservationPage() {
|
|||||||
}
|
}
|
||||||
setSavingCustomer(true)
|
setSavingCustomer(true)
|
||||||
try {
|
try {
|
||||||
const created = await apiFetch<CreatedCustomer>('/customers', {
|
const created = await apiFetch<Customer>('/customers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
firstName: bilingualPrimary(newCustomerFirstName).trim(),
|
firstName: bilingualPrimary(newCustomerFirstName).trim(),
|
||||||
@@ -295,6 +344,11 @@ export default function NewReservationPage() {
|
|||||||
setNewCustomerLastName(emptyBilingual())
|
setNewCustomerLastName(emptyBilingual())
|
||||||
setNewCustomerEmail('')
|
setNewCustomerEmail('')
|
||||||
setNewCustomerPhone('')
|
setNewCustomerPhone('')
|
||||||
|
setLicenseImageUrl(created.licenseImageUrl ?? null)
|
||||||
|
|
||||||
|
if (licenseImageFile) {
|
||||||
|
await uploadLicenseImage(created.id)
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -332,6 +386,10 @@ export default function NewReservationPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (licenseImageFile) {
|
||||||
|
await uploadLicenseImage(customerId)
|
||||||
|
}
|
||||||
|
|
||||||
const created = await apiFetch<CreatedReservation>('/reservations', {
|
const created = await apiFetch<CreatedReservation>('/reservations', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -477,6 +535,32 @@ export default function NewReservationPage() {
|
|||||||
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="space-y-1 block">
|
||||||
|
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
|
||||||
|
{licenseImageFile ? (
|
||||||
|
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
|
||||||
|
) : licenseImageUrl ? (
|
||||||
|
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
|
||||||
|
)}
|
||||||
|
{licenseImagePreviewUrl || licenseImageUrl ? (
|
||||||
|
<img
|
||||||
|
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
|
||||||
|
alt="Driver license preview"
|
||||||
|
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -86,12 +86,15 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
env_file:
|
env_file:
|
||||||
- .env.docker.dev
|
- .env.docker.dev
|
||||||
|
environment:
|
||||||
|
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
|
||||||
command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/api"]
|
command: ["npm", "run", "dev", "--workspace", "@rentaldrivego/api"]
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- app_node_modules:/app/node_modules
|
- app_node_modules:/app/node_modules
|
||||||
|
- api_uploads_dev:/var/lib/rentaldrivego/storage
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["api", "full"]
|
profiles: ["api", "full"]
|
||||||
|
|
||||||
@@ -153,3 +156,4 @@ volumes:
|
|||||||
pgmanage_dev_data:
|
pgmanage_dev_data:
|
||||||
app_node_modules:
|
app_node_modules:
|
||||||
postgres_bootstrap_state:
|
postgres_bootstrap_state:
|
||||||
|
api_uploads_dev:
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ services:
|
|||||||
- .env.docker.production
|
- .env.docker.production
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true}
|
DATABASE_URL_FROM_POSTGRES: ${DATABASE_URL_FROM_POSTGRES:-true}
|
||||||
|
FILE_STORAGE_ROOT: /var/lib/rentaldrivego/storage
|
||||||
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
|
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
|
||||||
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego}
|
POSTGRES_DB: ${POSTGRES_DB:-rentaldrivego}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
volumes:
|
||||||
|
- api_uploads:/var/lib/rentaldrivego/storage
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
@@ -176,3 +179,4 @@ volumes:
|
|||||||
postgres_prod_data:
|
postgres_prod_data:
|
||||||
redis_prod_data:
|
redis_prod_data:
|
||||||
pgmanage_prod_data:
|
pgmanage_prod_data:
|
||||||
|
api_uploads:
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "customers"
|
||||||
|
ADD COLUMN "licenseImageUrl" TEXT;
|
||||||
@@ -609,6 +609,7 @@ model Customer {
|
|||||||
licenseExpiry DateTime?
|
licenseExpiry DateTime?
|
||||||
licenseIssuedAt DateTime?
|
licenseIssuedAt DateTime?
|
||||||
licenseCountry String?
|
licenseCountry String?
|
||||||
|
licenseImageUrl String?
|
||||||
licenseNumber String?
|
licenseNumber String?
|
||||||
licenseCategory String?
|
licenseCategory String?
|
||||||
licenseExpired Boolean @default(false)
|
licenseExpired Boolean @default(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user