mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
31f99f0e4e
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767) * fix(notifications): show notification and admin times in the viewer timezone (#1149) SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed it as local time. Normalize in-app notification created_at to ISO-UTC, and stop forcing the admin user table to render in the server timezone. * fix(places): warn before adding a duplicate place (#1152) Manually adding a place did not check the existing pool, so the same POI could land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or near-identical coordinates and require a confirming second click to add anyway.
838 lines
30 KiB
TypeScript
838 lines
30 KiB
TypeScript
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
|
import Modal from '../shared/Modal'
|
|
import CustomSelect from '../shared/CustomSelect'
|
|
import { mapsApi } from '../../api/client'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useToast } from '../shared/Toast'
|
|
import { Search, Paperclip, X, AlertTriangle, Loader2 } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
|
import { DEFAULT_FORM, isGoogleMapsUrl, type PlaceFormData } from './PlaceFormModal.helpers'
|
|
import { getApiErrorMessage } from '../../utils/apiError'
|
|
import type { Place, Category, Assignment } from '../../types'
|
|
|
|
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
|
// category_id is normalised, plus any files chosen before the place existed.
|
|
export interface PlaceSubmitData extends Omit<PlaceFormData, 'lat' | 'lng' | 'category_id'> {
|
|
lat: number | null
|
|
lng: number | null
|
|
category_id: string | null
|
|
_pendingFiles?: File[]
|
|
}
|
|
|
|
interface PlaceFormModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
|
place: Place | null
|
|
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
|
tripId: number
|
|
categories: Category[]
|
|
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
|
assignmentId: number | null
|
|
dayAssignments?: Assignment[]
|
|
}
|
|
|
|
|
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
|
* render over the form fields. */
|
|
|
|
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
|
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
|
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
|
const DUP_COORD_TOLERANCE = 0.0001
|
|
function findDuplicatePlace(
|
|
form: PlaceFormData,
|
|
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
|
): { name?: string | null } | null {
|
|
const name = (form.name || '').trim().toLowerCase()
|
|
const gid = (form.google_place_id || '').trim()
|
|
const lat = form.lat ? parseFloat(form.lat) : null
|
|
const lng = form.lng ? parseFloat(form.lng) : null
|
|
for (const p of places || []) {
|
|
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
|
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
|
if (
|
|
lat != null && lng != null && p.lat != null && p.lng != null &&
|
|
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
|
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
|
) return p
|
|
}
|
|
return null
|
|
}
|
|
|
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
|
const {
|
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
|
onCategoryCreated, assignmentId, dayAssignments = [],
|
|
} = props
|
|
const [form, setForm] = useState(DEFAULT_FORM)
|
|
const [mapsSearch, setMapsSearch] = useState('')
|
|
const [mapsResults, setMapsResults] = useState([])
|
|
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
|
const [newCategoryName, setNewCategoryName] = useState('')
|
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
|
const [pendingFiles, setPendingFiles] = useState([])
|
|
const fileRef = useRef(null)
|
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
|
const [acHighlight, setAcHighlight] = useState(-1)
|
|
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const acAbortRef = useRef<AbortController | null>(null)
|
|
const toast = useToast()
|
|
const { t, language } = useTranslation()
|
|
const { hasMapsKey } = useAuthStore()
|
|
const can = useCanDo()
|
|
const tripObj = useTripStore((s) => s.trip)
|
|
const canUploadFiles = can('file_upload', tripObj)
|
|
|
|
useEffect(() => {
|
|
if (place) {
|
|
setForm({
|
|
name: place.name || '',
|
|
description: place.description || '',
|
|
address: place.address || '',
|
|
lat: place.lat != null ? String(place.lat) : '',
|
|
lng: place.lng != null ? String(place.lng) : '',
|
|
category_id: place.category_id != null ? String(place.category_id) : '',
|
|
place_time: place.place_time || '',
|
|
end_time: place.end_time || '',
|
|
notes: place.notes || '',
|
|
transport_mode: place.transport_mode || 'walking',
|
|
website: place.website || '',
|
|
})
|
|
} else if (prefillCoords) {
|
|
setForm({
|
|
...DEFAULT_FORM,
|
|
lat: String(prefillCoords.lat),
|
|
lng: String(prefillCoords.lng),
|
|
name: prefillCoords.name || '',
|
|
address: prefillCoords.address || '',
|
|
website: prefillCoords.website || '',
|
|
phone: prefillCoords.phone || '',
|
|
osm_id: prefillCoords.osm_id,
|
|
})
|
|
} else {
|
|
setForm(DEFAULT_FORM)
|
|
}
|
|
setPendingFiles([])
|
|
setDuplicateWarning(null)
|
|
}, [place, prefillCoords, isOpen])
|
|
|
|
// Derive location bias bounding box from the trip's existing places
|
|
const places = useTripStore((s) => s.places)
|
|
const locationBias = useMemo(() => {
|
|
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
|
if (withCoords.length === 0) return undefined
|
|
|
|
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
|
for (const p of withCoords) {
|
|
const lat = Number(p.lat), lng = Number(p.lng)
|
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
|
if (lat < minLat) minLat = lat
|
|
if (lat > maxLat) maxLat = lat
|
|
if (lng < minLng) minLng = lng
|
|
if (lng > maxLng) maxLng = lng
|
|
}
|
|
if (!Number.isFinite(minLat)) return undefined
|
|
|
|
// Skip bias if the bounding box is too large (~500 km diagonal)
|
|
const dlat = maxLat - minLat
|
|
const dlng = maxLng - minLng
|
|
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
|
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
|
if (diagKm > 500) return undefined
|
|
|
|
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
|
}, [places])
|
|
|
|
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
|
const fetchSuggestions = useCallback(async (query: string) => {
|
|
if (query.length < 2 || isGoogleMapsUrl(query)) {
|
|
setAcSuggestions([])
|
|
setAcHighlight(-1)
|
|
return
|
|
}
|
|
acAbortRef.current?.abort()
|
|
const controller = new AbortController()
|
|
acAbortRef.current = controller
|
|
try {
|
|
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
|
|
setAcSuggestions(result.suggestions || [])
|
|
setAcHighlight(-1)
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && err.name === 'AbortError') return
|
|
if (err instanceof Error && err.name === 'CanceledError') return // axios abort
|
|
console.error('Autocomplete failed:', err)
|
|
setAcSuggestions([])
|
|
}
|
|
}, [language, locationBias])
|
|
|
|
// Debounce effect — only watches mapsSearch
|
|
useEffect(() => {
|
|
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
|
|
|
const trimmed = mapsSearch.trim()
|
|
if (trimmed.length < 2 || isGoogleMapsUrl(trimmed)) {
|
|
setAcSuggestions([])
|
|
setAcHighlight(-1)
|
|
return
|
|
}
|
|
|
|
acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
|
|
|
|
return () => {
|
|
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
|
|
}
|
|
}, [mapsSearch, fetchSuggestions])
|
|
|
|
const handleChange = (field: string, value: string) => {
|
|
setForm(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const handleMapsSearch = async () => {
|
|
if (!mapsSearch.trim()) return
|
|
setIsSearchingMaps(true)
|
|
try {
|
|
// Detect Google Maps URLs and resolve them directly
|
|
const trimmed = mapsSearch.trim()
|
|
if (isGoogleMapsUrl(trimmed)) {
|
|
const resolved = await mapsApi.resolveUrl(trimmed)
|
|
if (resolved.lat && resolved.lng) {
|
|
setForm(prev => ({
|
|
...prev,
|
|
name: resolved.name || prev.name,
|
|
address: resolved.address || prev.address,
|
|
lat: String(resolved.lat),
|
|
lng: String(resolved.lng),
|
|
}))
|
|
setMapsResults([])
|
|
setMapsSearch('')
|
|
toast.success(t('places.urlResolved'))
|
|
return
|
|
}
|
|
}
|
|
const result = await mapsApi.search(mapsSearch, language)
|
|
setMapsResults(result.places || [])
|
|
} catch (err: unknown) {
|
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
|
} finally {
|
|
setIsSearchingMaps(false)
|
|
}
|
|
}
|
|
|
|
const handleSelectMapsResult = (result) => {
|
|
setForm(prev => ({
|
|
...prev,
|
|
name: result.name || prev.name,
|
|
address: result.address || prev.address,
|
|
lat: result.lat || prev.lat,
|
|
lng: result.lng || prev.lng,
|
|
google_place_id: result.google_place_id || prev.google_place_id,
|
|
osm_id: result.osm_id || prev.osm_id,
|
|
website: result.website || prev.website,
|
|
phone: result.phone || prev.phone,
|
|
}))
|
|
setMapsResults([])
|
|
setMapsSearch('')
|
|
}
|
|
|
|
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
|
setAcSuggestions([])
|
|
setAcHighlight(-1)
|
|
const previousSearch = mapsSearch
|
|
setMapsSearch('')
|
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
|
setIsSearchingMaps(true)
|
|
try {
|
|
const result = await mapsApi.details(suggestion.placeId, language)
|
|
if (result.place) {
|
|
handleSelectMapsResult(result.place)
|
|
} else {
|
|
setMapsSearch(previousSearch)
|
|
toast.error(t('places.mapsSearchError'))
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch place details:', err)
|
|
setMapsSearch(previousSearch)
|
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
|
} finally {
|
|
setIsSearchingMaps(false)
|
|
}
|
|
}
|
|
|
|
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
|
if (acSuggestions.length > 0) {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
setAcHighlight(prev => (prev + 1) % acSuggestions.length)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
setAcHighlight(prev => (prev <= 0 ? acSuggestions.length - 1 : prev - 1))
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
if (acHighlight >= 0) {
|
|
handleSelectSuggestion(acSuggestions[acHighlight])
|
|
} else {
|
|
setAcSuggestions([])
|
|
handleMapsSearch()
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setAcSuggestions([])
|
|
setAcHighlight(-1)
|
|
}
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleMapsSearch()
|
|
}
|
|
}
|
|
|
|
const handleCreateCategory = async () => {
|
|
if (!newCategoryName.trim()) return
|
|
try {
|
|
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' })
|
|
if (cat) setForm(prev => ({ ...prev, category_id: String(cat.id) }))
|
|
setNewCategoryName('')
|
|
setShowNewCategory(false)
|
|
} catch (err: unknown) {
|
|
toast.error(t('places.categoryCreateError'))
|
|
}
|
|
}
|
|
|
|
const handleFileAdd = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || [])
|
|
setPendingFiles(prev => [...prev, ...files])
|
|
e.target.value = ''
|
|
}
|
|
|
|
const handleRemoveFile = (idx: number) => {
|
|
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
|
|
}
|
|
|
|
// Paste support for files/images
|
|
const handlePaste = (e: React.ClipboardEvent) => {
|
|
if (!canUploadFiles) return
|
|
const items = e.clipboardData?.items
|
|
if (!items) return
|
|
for (const item of Array.from(items)) {
|
|
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
|
e.preventDefault()
|
|
const file = item.getAsFile()
|
|
if (file) setPendingFiles(prev => [...prev, file])
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
if (!form.name.trim()) {
|
|
toast.error(t('places.nameRequired'))
|
|
return
|
|
}
|
|
// #1152: only for new places, and only on the first attempt — a second click
|
|
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
|
if (!place && !duplicateWarning) {
|
|
const dup = findDuplicatePlace(form, places)
|
|
if (dup) {
|
|
const dupName = dup.name || form.name
|
|
setDuplicateWarning(dupName)
|
|
toast.warning(t('places.duplicateExists', { name: dupName }))
|
|
return
|
|
}
|
|
}
|
|
setIsSaving(true)
|
|
try {
|
|
await onSave({
|
|
...form,
|
|
lat: form.lat ? parseFloat(form.lat) : null,
|
|
lng: form.lng ? parseFloat(form.lng) : null,
|
|
category_id: form.category_id || null,
|
|
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
|
})
|
|
onClose()
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
return {
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
place,
|
|
prefillCoords,
|
|
tripId,
|
|
categories,
|
|
onCategoryCreated,
|
|
assignmentId,
|
|
dayAssignments,
|
|
form,
|
|
setForm,
|
|
mapsSearch,
|
|
setMapsSearch,
|
|
mapsResults,
|
|
setMapsResults,
|
|
isSearchingMaps,
|
|
setIsSearchingMaps,
|
|
newCategoryName,
|
|
setNewCategoryName,
|
|
showNewCategory,
|
|
setShowNewCategory,
|
|
isSaving,
|
|
setIsSaving,
|
|
pendingFiles,
|
|
setPendingFiles,
|
|
fileRef,
|
|
acSuggestions,
|
|
setAcSuggestions,
|
|
acHighlight,
|
|
setAcHighlight,
|
|
acDebounceRef,
|
|
acAbortRef,
|
|
toast,
|
|
t,
|
|
language,
|
|
hasMapsKey,
|
|
can,
|
|
tripObj,
|
|
canUploadFiles,
|
|
places,
|
|
locationBias,
|
|
fetchSuggestions,
|
|
handleChange,
|
|
handleMapsSearch,
|
|
handleSelectMapsResult,
|
|
handleSelectSuggestion,
|
|
handleSearchKeyDown,
|
|
handleCreateCategory,
|
|
handleFileAdd,
|
|
handleRemoveFile,
|
|
handlePaste,
|
|
hasTimeError,
|
|
handleSubmit,
|
|
duplicateWarning,
|
|
}
|
|
}
|
|
|
|
export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|
const S = usePlaceFormModal(props)
|
|
const {
|
|
isOpen,
|
|
onClose,
|
|
onSave,
|
|
place,
|
|
prefillCoords,
|
|
tripId,
|
|
categories,
|
|
onCategoryCreated,
|
|
assignmentId,
|
|
dayAssignments,
|
|
form,
|
|
setForm,
|
|
mapsSearch,
|
|
setMapsSearch,
|
|
mapsResults,
|
|
setMapsResults,
|
|
isSearchingMaps,
|
|
setIsSearchingMaps,
|
|
newCategoryName,
|
|
setNewCategoryName,
|
|
showNewCategory,
|
|
setShowNewCategory,
|
|
isSaving,
|
|
setIsSaving,
|
|
pendingFiles,
|
|
setPendingFiles,
|
|
fileRef,
|
|
acSuggestions,
|
|
setAcSuggestions,
|
|
acHighlight,
|
|
setAcHighlight,
|
|
acDebounceRef,
|
|
acAbortRef,
|
|
toast,
|
|
t,
|
|
language,
|
|
hasMapsKey,
|
|
can,
|
|
tripObj,
|
|
canUploadFiles,
|
|
places,
|
|
locationBias,
|
|
fetchSuggestions,
|
|
handleChange,
|
|
handleMapsSearch,
|
|
handleSelectMapsResult,
|
|
handleSelectSuggestion,
|
|
handleSearchKeyDown,
|
|
handleCreateCategory,
|
|
handleFileAdd,
|
|
handleRemoveFile,
|
|
handlePaste,
|
|
hasTimeError,
|
|
handleSubmit,
|
|
duplicateWarning,
|
|
} = S
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
|
size="lg"
|
|
footer={
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
|
|
>
|
|
{t('common.cancel')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleSubmit}
|
|
disabled={isSaving || hasTimeError}
|
|
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
|
>
|
|
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
|
</button>
|
|
</div>
|
|
}
|
|
>
|
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
|
{/* Place Search */}
|
|
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
|
{!hasMapsKey && (
|
|
<p className="mb-2 text-xs text-content-faint">
|
|
{t('places.osmActive')}
|
|
</p>
|
|
)}
|
|
<div className="relative">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={mapsSearch}
|
|
onChange={e => setMapsSearch(e.target.value)}
|
|
onKeyDown={handleSearchKeyDown}
|
|
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
|
|
onFocus={() => {
|
|
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
|
|
fetchSuggestions(mapsSearch.trim())
|
|
}
|
|
}}
|
|
placeholder={t('places.mapsSearchPlaceholder')}
|
|
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => { setAcSuggestions([]); handleMapsSearch() }}
|
|
disabled={isSearchingMaps}
|
|
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
|
|
>
|
|
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Autocomplete dropdown */}
|
|
{acSuggestions.length > 0 && (
|
|
<div className="absolute left-0 right-0 z-20 mt-1 bg-white rounded-lg border border-slate-200 shadow-lg overflow-hidden">
|
|
{acSuggestions.map((s, idx) => (
|
|
<button
|
|
key={s.placeId}
|
|
type="button"
|
|
onMouseDown={() => handleSelectSuggestion(s)}
|
|
onMouseEnter={() => setAcHighlight(idx)}
|
|
className={`w-full text-left px-3 py-2 border-b border-slate-100 last:border-0 ${
|
|
idx === acHighlight ? 'bg-slate-100' : 'hover:bg-slate-50'
|
|
}`}
|
|
>
|
|
<div className="font-medium text-sm">{s.mainText}</div>
|
|
{s.secondaryText && (
|
|
<div className="text-xs text-slate-500 truncate">{s.secondaryText}</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search results (populated after full search) */}
|
|
{mapsResults.length > 0 && (
|
|
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
|
|
{mapsResults.map((result, idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => handleSelectMapsResult(result)}
|
|
className="w-full text-left px-3 py-2 hover:bg-slate-50 border-b border-slate-100 last:border-0"
|
|
>
|
|
<div className="font-medium text-sm">{result.name}</div>
|
|
<div className="text-xs text-slate-500 truncate">{result.address}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Name */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={e => handleChange('name', e.target.value)}
|
|
required
|
|
placeholder={t('places.formNamePlaceholder')}
|
|
className="form-input"
|
|
/>
|
|
{isSearchingMaps && (
|
|
<div className="absolute right-2.5 top-0 bottom-0 flex items-center" role="status" aria-label={t('places.loadingDetails')}>
|
|
<Loader2 className="w-4 h-4 animate-spin text-slate-400" aria-hidden="true" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formDescription')}</label>
|
|
<textarea
|
|
value={form.description}
|
|
onChange={e => handleChange('description', e.target.value)}
|
|
rows={2}
|
|
placeholder={t('places.formDescriptionPlaceholder')}
|
|
className="form-input" style={{ resize: 'vertical' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formNotes')}</label>
|
|
<textarea
|
|
value={form.notes}
|
|
onChange={e => handleChange('notes', e.target.value)}
|
|
rows={3}
|
|
maxLength={2000}
|
|
placeholder={t('places.formNotesPlaceholder')}
|
|
className="form-input" style={{ resize: 'vertical' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Address + Coordinates */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formAddress')}</label>
|
|
<input
|
|
type="text"
|
|
value={form.address}
|
|
onChange={e => handleChange('address', e.target.value)}
|
|
placeholder={t('places.formAddressPlaceholder')}
|
|
className="form-input"
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={form.lat}
|
|
onChange={e => handleChange('lat', e.target.value)}
|
|
onPaste={e => {
|
|
const text = e.clipboardData.getData('text').trim()
|
|
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
|
if (match) {
|
|
e.preventDefault()
|
|
handleChange('lat', match[1])
|
|
handleChange('lng', match[2])
|
|
}
|
|
}}
|
|
placeholder={t('places.formLat')}
|
|
className="form-input"
|
|
/>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={form.lng}
|
|
onChange={e => handleChange('lng', e.target.value)}
|
|
placeholder={t('places.formLng')}
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formCategory')}</label>
|
|
{!showNewCategory ? (
|
|
<div className="flex gap-2">
|
|
<CustomSelect
|
|
value={form.category_id}
|
|
onChange={value => handleChange('category_id', String(value))}
|
|
placeholder={t('places.noCategory')}
|
|
options={[
|
|
{ value: '', label: t('places.noCategory') },
|
|
...(categories || []).map(c => ({
|
|
// form.category_id is a string; CustomSelect matches options by
|
|
// strict equality, so the option value must be a string too —
|
|
// otherwise the chosen category never renders in the trigger.
|
|
value: String(c.id),
|
|
label: c.name,
|
|
})),
|
|
]}
|
|
style={{ flex: 1 }}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newCategoryName}
|
|
onChange={e => setNewCategoryName(e.target.value)}
|
|
placeholder={t('places.categoryNamePlaceholder')}
|
|
className="form-input" style={{ flex: 1 }}
|
|
/>
|
|
<button type="button" onClick={handleCreateCategory} className="bg-slate-900 text-white px-3 rounded-lg hover:bg-slate-700 text-sm">
|
|
OK
|
|
</button>
|
|
<button type="button" onClick={() => setShowNewCategory(false)} className="text-gray-500 px-2 text-sm">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Time — only shown when editing, not when creating */}
|
|
{place && (
|
|
<TimeSection
|
|
form={form}
|
|
handleChange={handleChange}
|
|
assignmentId={assignmentId}
|
|
dayAssignments={dayAssignments}
|
|
hasTimeError={hasTimeError}
|
|
t={t}
|
|
/>
|
|
)}
|
|
|
|
{/* Website */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formWebsite')}</label>
|
|
<input
|
|
type="url"
|
|
value={form.website}
|
|
onChange={e => handleChange('website', e.target.value)}
|
|
placeholder="https://..."
|
|
className="form-input"
|
|
/>
|
|
</div>
|
|
|
|
{/* File Attachments */}
|
|
{canUploadFiles && (
|
|
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
|
<button type="button" onClick={() => fileRef.current?.click()}
|
|
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
|
|
<Paperclip size={12} /> {t('files.attach')}
|
|
</button>
|
|
</div>
|
|
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileAdd} />
|
|
{pendingFiles.length > 0 && (
|
|
<div className="space-y-1">
|
|
{pendingFiles.map((file, idx) => (
|
|
<div key={idx} className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-slate-50 text-xs">
|
|
<Paperclip size={10} className="text-slate-400 shrink-0" />
|
|
<span className="truncate flex-1 text-slate-600">{file.name}</span>
|
|
<button type="button" onClick={() => handleRemoveFile(idx)} className="text-slate-400 hover:text-red-500 shrink-0">
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{pendingFiles.length === 0 && (
|
|
<p className="text-xs text-slate-400">{t('files.pasteHint')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</form>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
interface TimeSectionProps {
|
|
form: PlaceFormData
|
|
handleChange: (field: string, value: string) => void
|
|
assignmentId: number | null
|
|
dayAssignments: Assignment[]
|
|
hasTimeError: boolean
|
|
t: (key: string, params?: Record<string, string | number>) => string
|
|
}
|
|
|
|
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
|
|
|
const collisions = useMemo(() => {
|
|
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
|
// Find the day_id for the current assignment
|
|
const current = dayAssignments.find(a => a.id === assignmentId)
|
|
if (!current) return []
|
|
const myStart = form.place_time
|
|
const myEnd = form.end_time && form.end_time.length >= 5 ? form.end_time : null
|
|
return dayAssignments.filter(a => {
|
|
if (a.id === assignmentId) return false
|
|
if (a.day_id !== current.day_id) return false
|
|
const aStart = a.place?.place_time
|
|
const aEnd = a.place?.end_time
|
|
if (!aStart) return false
|
|
// Check overlap: two intervals overlap if start < otherEnd AND otherStart < end
|
|
const s1 = myStart, e1 = myEnd || myStart
|
|
const s2 = aStart, e2 = aEnd || aStart
|
|
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
|
|
})
|
|
}, [assignmentId, dayAssignments, form.place_time, form.end_time])
|
|
|
|
return (
|
|
<div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
|
|
<CustomTimePicker
|
|
value={form.place_time}
|
|
onChange={v => handleChange('place_time', v)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
|
|
<CustomTimePicker
|
|
value={form.end_time}
|
|
onChange={v => handleChange('end_time', v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{hasTimeError && (
|
|
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
|
<AlertTriangle size={13} className="shrink-0" />
|
|
{t('places.endTimeBeforeStart')}
|
|
</div>
|
|
)}
|
|
{collisions.length > 0 && (
|
|
<div className="flex items-start gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
|
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
|
|
<span>
|
|
{t('places.timeCollision')}{' '}
|
|
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|