mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(places): bulk "change category" from the selection toolbar
Closes the UI half of #1168: in the Places selection mode, a new tag button before delete opens a category picker that applies one category (or "No category") to every selected place in a single request. Adds a REST /places/bulk-update endpoint reusing updatePlacesMany, an offline-aware repo + store action that patches both the place pool and the day-assignment projections, undo grouped by each place's prior category, and the i18n keys across all locales.
This commit is contained in:
@@ -27,6 +27,7 @@ import {
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
@@ -379,6 +380,8 @@ export const placesApi = {
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-update`, { ids, ...data } satisfies PlaceBulkUpdateRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, MapPin } from 'lucide-react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Category } from '../../types'
|
||||
|
||||
interface PlacesBulkCategoryModalProps {
|
||||
count: number
|
||||
categories: Category[]
|
||||
onPick: (categoryId: number | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex', alignItems: 'center', gap: 9, width: '100%',
|
||||
padding: '8px 10px', borderRadius: 7, border: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))', textAlign: 'left',
|
||||
}
|
||||
const hoverOn = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'var(--bg-hover)' }
|
||||
const hoverOff = (e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.background = 'transparent' }
|
||||
|
||||
/**
|
||||
* Popup for the Places selection toolbar: pick one category to apply to every
|
||||
* currently-selected place. Reuses the category swatch styling from the header's
|
||||
* filter dropdown; clicking a row applies immediately and closes.
|
||||
*/
|
||||
export function PlacesBulkCategoryModal({ count, categories, onPick, onClose }: PlacesBulkCategoryModalProps) {
|
||||
const { t } = useTranslation()
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div className="bg-surface-card text-content" style={{
|
||||
borderRadius: 14, padding: '18px 20px', width: '100%', maxWidth: 380,
|
||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', border: '1px solid var(--border-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 14 }}>{t('places.changeCategory')}</span>
|
||||
<button onClick={onClose} aria-label={t('common.close')} className="text-content-muted" style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex' }}>
|
||||
<X size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-content-faint" style={{ fontSize: 12, marginBottom: 12 }}>{t('places.selectionCount', { count })}</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 300, overflowY: 'auto' }}>
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
<button key={c.id} onClick={() => onPick(c.id)} className="text-content bg-transparent" style={rowStyle} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<CatIcon size={14} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||
<span style={{ flex: 1 }}>{c.name}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button onClick={() => onPick(null)} className="text-content-muted bg-transparent" style={{ ...rowStyle, borderTop: categories.length > 0 ? '1px solid var(--border-faint)' : 'none', marginTop: categories.length > 0 ? 2 : 0 }} onMouseEnter={hoverOn} onMouseLeave={hoverOff}>
|
||||
<MapPin size={14} strokeWidth={2} color="var(--text-faint)" />
|
||||
<span style={{ flex: 1 }}>{t('places.noCategory')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PlacesSelectionBar } from './PlacesSidebarSelectionBar'
|
||||
import { PlacesList } from './PlacesSidebarList'
|
||||
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
|
||||
import { ListImportModal } from './PlacesSidebarListImportModal'
|
||||
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
|
||||
const S = usePlacesSidebar(props)
|
||||
@@ -16,6 +17,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
selectMode, filtered, t, dayPickerPlace, listImportOpen,
|
||||
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
|
||||
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
|
||||
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
|
||||
} = S
|
||||
return (
|
||||
<div
|
||||
@@ -51,6 +53,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{categoryPickerOpen && (
|
||||
<PlacesBulkCategoryModal
|
||||
count={selectedIds.size}
|
||||
categories={categories}
|
||||
onClose={() => setCategoryPickerOpen(false)}
|
||||
onPick={(catId) => { onBulkChangeCategory?.(Array.from(selectedIds), catId); setCategoryPickerOpen(false); exitSelectMode() }}
|
||||
/>
|
||||
)}
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Check, Trash2 } from 'lucide-react'
|
||||
import { Check, Tag, Trash2 } from 'lucide-react'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import type { SidebarState } from './usePlacesSidebar'
|
||||
|
||||
export function PlacesSelectionBar(S: SidebarState) {
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces } = S
|
||||
const { t, selectedIds, filtered, setSelectedIds, isMobile, setPendingDeleteIds, onBulkDeletePlaces, setCategoryPickerOpen } = S
|
||||
return (
|
||||
<div style={{
|
||||
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||
@@ -32,6 +32,23 @@ export function PlacesSelectionBar(S: SidebarState) {
|
||||
<Check size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.changeCategory')} placement="bottom">
|
||||
<button
|
||||
onClick={() => { if (selectedIds.size === 0) return; setCategoryPickerOpen(true) }}
|
||||
disabled={selectedIds.size === 0}
|
||||
aria-label={t('places.changeCategory')}
|
||||
className={selectedIds.size > 0 ? 'bg-transparent text-content-muted' : 'bg-transparent text-content-faint'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Tag size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface PlacesSidebarProps {
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -147,6 +148,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
@@ -258,6 +260,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||
categoryPickerOpen, setCategoryPickerOpen,
|
||||
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
||||
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
||||
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
||||
|
||||
@@ -203,7 +203,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
@@ -468,6 +468,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onEditPlace={(place) => openPlaceEditor(place)}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
onPlacesFilterChange={setMapPlacesFilter}
|
||||
pushUndo={pushUndo}
|
||||
@@ -610,7 +611,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,6 +525,32 @@ export function useTripPlanner() {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
|
||||
const confirmChangeCategory = useCallback(async (ids: number[], categoryId: number | null) => {
|
||||
if (!ids.length) return
|
||||
const state = useTripStore.getState()
|
||||
// Capture each place's prior category so undo can restore them per group.
|
||||
const captured = state.places.filter(p => ids.includes(p.id)).map(p => ({ id: p.id, prev: p.category_id ?? null }))
|
||||
try {
|
||||
await tripActions.updatePlacesMany(tripId, ids, { category_id: categoryId })
|
||||
toast.success(t('places.categoryChanged', { count: ids.length }))
|
||||
if (captured.length > 0) {
|
||||
pushUndo(t('undo.changeCategory'), async () => {
|
||||
// Group the captured ids by their prior category so each set is restored
|
||||
// in one call ('null' key = previously uncategorized). Map is shadowed by
|
||||
// the lucide icon import in this file, so use a plain object.
|
||||
const byPrev: Record<string, number[]> = {}
|
||||
for (const { id, prev } of captured) {
|
||||
const key = prev === null ? 'null' : String(prev)
|
||||
;(byPrev[key] ??= []).push(id)
|
||||
}
|
||||
for (const [key, group] of Object.entries(byPrev)) {
|
||||
await tripActions.updatePlacesMany(tripId, group, { category_id: key === 'null' ? null : Number(key) })
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId: number, dayId?: number, position?: number) => {
|
||||
const target = dayId || selectedDayId
|
||||
if (!target) { toast.error(t('trip.toast.selectDay')); return }
|
||||
@@ -841,7 +867,7 @@ export function useTripPlanner() {
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, confirmChangeCategory,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
|
||||
@@ -115,4 +115,31 @@ export const placeRepo = {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
return result
|
||||
},
|
||||
|
||||
async updateMany(tripId: number | string, ids: number[], data: Record<string, unknown>): Promise<{ updated: number[]; count: number }> {
|
||||
if (!navigator.onLine) {
|
||||
// Offline fans out one queued PUT per id (mirrors deleteMany's DELETE fan-out).
|
||||
for (const id of ids) {
|
||||
const existing = await offlineDb.places.get(id)
|
||||
if (existing) await offlineDb.places.put({ ...existing, ...(data as Partial<Place>) })
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
}
|
||||
return { updated: ids, count: ids.length }
|
||||
}
|
||||
const result = await placesApi.bulkUpdate(tripId, ids, data as Parameters<typeof placesApi.bulkUpdate>[2])
|
||||
const cached = await offlineDb.places.bulkGet(ids)
|
||||
await offlineDb.places.bulkPut(cached.filter(Boolean).map(p => ({ ...(p as Place), ...(data as Partial<Place>) })))
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PlacesSlice {
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
updatePlacesMany: (tripId: number | string, placeIds: number[], patch: Partial<Place>) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
@@ -105,4 +106,33 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
|
||||
}
|
||||
},
|
||||
|
||||
updatePlacesMany: async (tripId, placeIds, patch) => {
|
||||
if (placeIds.length === 0) return
|
||||
try {
|
||||
await placeRepo.updateMany(tripId, placeIds, patch as Record<string, unknown>)
|
||||
const idSet = new Set(placeIds)
|
||||
set(state => {
|
||||
// Patch both the place pool and the embedded place on each day assignment
|
||||
// (preserving the assignment's own place_time/end_time) so itinerary cards
|
||||
// reflect the change immediately, like single updatePlace does.
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
|
||||
updatedAssignments[dayId] = items.map((a: Assignment) =>
|
||||
a.place?.id != null && idSet.has(a.place.id) ? { ...a, place: { ...a.place, ...patch } } : a
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.map(p => idSet.has(p.id) ? { ...p, ...patch } : p),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating places'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -91,6 +91,35 @@ describe('placesSlice', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlacesMany', () => {
|
||||
it('FE-PLACES-008: applies the patch to every listed place and cascades to assignments', async () => {
|
||||
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
|
||||
const b = buildPlace({ id: 20, trip_id: 1, category_id: 1 });
|
||||
const c = buildPlace({ id: 30, trip_id: 1, category_id: 9 });
|
||||
const assignment = buildAssignment({ id: 100, day_id: 1, place: a });
|
||||
seedStore(useTripStore, { places: [a, b, c], assignments: { '1': [assignment] } });
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places/bulk-update', () => HttpResponse.json({ updated: [10, 20], count: 2 })),
|
||||
);
|
||||
|
||||
await useTripStore.getState().updatePlacesMany(1, [10, 20], { category_id: 5 });
|
||||
|
||||
const places = useTripStore.getState().places;
|
||||
expect(places.find(p => p.id === 10)?.category_id).toBe(5);
|
||||
expect(places.find(p => p.id === 20)?.category_id).toBe(5);
|
||||
expect(places.find(p => p.id === 30)?.category_id).toBe(9); // untouched
|
||||
expect(useTripStore.getState().assignments['1'][0].place.category_id).toBe(5); // cascaded
|
||||
});
|
||||
|
||||
it('FE-PLACES-009: no-ops on an empty id list without calling the API', async () => {
|
||||
const a = buildPlace({ id: 10, trip_id: 1, category_id: 1 });
|
||||
seedStore(useTripStore, { places: [a] });
|
||||
await useTripStore.getState().updatePlacesMany(1, [], { category_id: 5 });
|
||||
expect(useTripStore.getState().places[0].category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePlace', () => {
|
||||
it('FE-PLACES-005: deletePlace removes place from places array', async () => {
|
||||
const place1 = buildPlace({ id: 10, trip_id: 1 });
|
||||
|
||||
@@ -225,6 +225,36 @@ export class PlacesController {
|
||||
return { deleted, count: deleted.length };
|
||||
}
|
||||
|
||||
@Post('bulk-update')
|
||||
@HttpCode(200)
|
||||
bulkUpdate(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { ids?: unknown; category_id?: unknown },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const { ids } = body;
|
||||
if (!Array.isArray(ids) || ids.some((v) => typeof v !== 'number')) {
|
||||
throw new HttpException({ error: 'ids must be an array of numbers' }, 400);
|
||||
}
|
||||
if (ids.length === 0) {
|
||||
return { updated: [], count: 0 };
|
||||
}
|
||||
// `category_id` may be a number or null (null clears it), so key presence —
|
||||
// not truthiness — is what signals there's something to change.
|
||||
if (!('category_id' in body)) {
|
||||
throw new HttpException({ error: 'Provide at least one field to update' }, 400);
|
||||
}
|
||||
const updated = this.places.updateMany(tripId, ids, { category_id: body.category_id as number | null });
|
||||
for (const place of updated) {
|
||||
this.places.broadcast(tripId, 'place:updated', { place }, socketId);
|
||||
this.places.onUpdated(place.id);
|
||||
}
|
||||
return { updated: updated.map((p) => p.id), count: updated.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@CurrentUser() user: User, @Param('tripId') tripId: string, @Param('id') id: string) {
|
||||
this.requireTrip(tripId, user);
|
||||
|
||||
@@ -52,6 +52,10 @@ export class PlacesService {
|
||||
return svc.deletePlacesMany(tripId, ids);
|
||||
}
|
||||
|
||||
updateMany(tripId: string, ids: number[], data: Parameters<typeof svc.updatePlacesMany>[2]) {
|
||||
return svc.updatePlacesMany(tripId, ids, data);
|
||||
}
|
||||
|
||||
importGpx(
|
||||
tripId: string,
|
||||
buffer: Buffer,
|
||||
|
||||
@@ -190,6 +190,38 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /bulk-update', () => {
|
||||
it('404 when trip not accessible, 403 without place_edit (before any write)', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new PlacesController(svc({ canEdit: vi.fn().mockReturnValue(false) })).bulkUpdate(user, '5', { ids: [1], category_id: 3 }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
it('400 when ids is not an array of numbers', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).bulkUpdate(user, '5', { ids: ['a'] }))).toEqual({ status: 400, body: { error: 'ids must be an array of numbers' } });
|
||||
});
|
||||
it('400 when no patch field is present', () => {
|
||||
expect(thrown(() => new PlacesController(svc()).bulkUpdate(user, '5', { ids: [1] }))).toEqual({ status: 400, body: { error: 'Provide at least one field to update' } });
|
||||
});
|
||||
it('returns empty for an empty list without touching the service', () => {
|
||||
const updateMany = vi.fn();
|
||||
expect(new PlacesController(svc({ updateMany } as Partial<PlacesService>)).bulkUpdate(user, '5', { ids: [] })).toEqual({ updated: [], count: 0 });
|
||||
expect(updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
it('updates, fires hooks + broadcasts per updated place', () => {
|
||||
const updateMany = vi.fn().mockReturnValue([{ id: 1 }, { id: 2 }]); const onUpdated = vi.fn(); const broadcast = vi.fn();
|
||||
const s = svc({ updateMany, onUpdated, broadcast } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).bulkUpdate(user, '5', { ids: [1, 2], category_id: 3 }, 'sock')).toEqual({ updated: [1, 2], count: 2 });
|
||||
expect(updateMany).toHaveBeenCalledWith('5', [1, 2], { category_id: 3 });
|
||||
expect(onUpdated).toHaveBeenCalledTimes(2);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'place:updated', { place: { id: 1 } }, 'sock');
|
||||
});
|
||||
it('passes category_id: null through to clear the category', () => {
|
||||
const updateMany = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
const s = svc({ updateMany } as Partial<PlacesService>);
|
||||
expect(new PlacesController(s).bulkUpdate(user, '5', { ids: [1], category_id: null })).toEqual({ updated: [1], count: 1 });
|
||||
expect(updateMany).toHaveBeenCalledWith('5', [1], { category_id: null });
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /:id returns the place when found, 404 when missing', () => {
|
||||
expect(thrown(() => new PlacesController(svc({ get: vi.fn().mockReturnValue(undefined) } as Partial<PlacesService>)).get(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
const s = svc({ get: vi.fn().mockReturnValue({ id: 9 }) } as Partial<PlacesService>);
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
|
||||
'places.selectionCount': '{count} محدد',
|
||||
'places.deleteSelected': 'حذف المحدد',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.importList': 'استيراد قائمة',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.deletePlaces': 'تم حذف الأماكن',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
|
||||
'places.selectionCount': '{count} selecionado(s)',
|
||||
'places.deleteSelected': 'Excluir seleção',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.importList': 'Importar lista',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.deletePlaces': 'Lugares excluídos',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Vyberte alespoň jeden typ.',
|
||||
'places.selectionCount': '{count} vybráno',
|
||||
'places.deleteSelected': 'Smazat vybrané',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.importList': 'Import seznamu',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.deletePlaces': 'Místa smazána',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Wähle mindestens einen Typ aus.',
|
||||
'places.selectionCount': '{count} ausgewählt',
|
||||
'places.deleteSelected': 'Auswahl löschen',
|
||||
'places.changeCategory': "Kategorie ändern",
|
||||
'places.categoryChanged': "{count} Orte aktualisiert",
|
||||
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.importList': 'Listenimport',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.deletePlaces': 'Orte gelöscht',
|
||||
'undo.changeCategory': "Kategorie geändert",
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.selectionCount': '{count} selected',
|
||||
'places.deleteSelected': 'Delete selected',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.importList': 'List Import',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.deletePlaces': 'Places deleted',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
|
||||
'places.selectionCount': '{count} seleccionado(s)',
|
||||
'places.deleteSelected': 'Eliminar selección',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.importList': 'Importar lista',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.deletePlaces': 'Lugares eliminados',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Sélectionnez au moins un type.',
|
||||
'places.selectionCount': '{count} sélectionné(s)',
|
||||
'places.deleteSelected': 'Supprimer la sélection',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
|
||||
'places.urlResolved': "Lieu importé depuis l'URL",
|
||||
'places.importList': 'Import de liste',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.deletePlaces': 'Lieux supprimés',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Επιλέξτε τουλάχιστον έναν τύπο για εισαγωγή.',
|
||||
'places.selectionCount': '{count} επιλεγμένα',
|
||||
'places.deleteSelected': 'Διαγραφή επιλεγμένων',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} μέρη εισήχθησαν από KMZ/KML',
|
||||
'places.urlResolved': 'Το μέρος εισήχθη από URL',
|
||||
'places.importList': 'Εισαγωγή Λίστας',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Η διαδρομή βελτιστοποιήθηκε',
|
||||
'undo.deletePlace': 'Η τοποθεσία διαγράφηκε',
|
||||
'undo.deletePlaces': 'Οι τοποθεσίες διαγράφηκαν',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Η τοποθεσία μεταφέρθηκε σε άλλη ημέρα',
|
||||
'undo.lock': 'Το κλείδωμα τοποθεσίας εναλλάχθηκε',
|
||||
'undo.importGpx': 'Εισαγωγή GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Válassz legalább egy típust.',
|
||||
'places.selectionCount': '{count} kiválasztva',
|
||||
'places.deleteSelected': 'Kijelöltek törlése',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.importList': 'Lista importálás',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.deletePlaces': 'Helyek törölve',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
|
||||
@@ -23,6 +23,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Pilih setidaknya satu jenis.',
|
||||
'places.selectionCount': '{count} dipilih',
|
||||
'places.deleteSelected': 'Hapus yang dipilih',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} tempat diimpor dari KMZ/KML',
|
||||
'places.urlResolved': 'Tempat diimpor dari URL',
|
||||
'places.importList': 'Impor Daftar',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Rute dioptimalkan',
|
||||
'undo.deletePlace': 'Tempat dihapus',
|
||||
'undo.deletePlaces': 'Tempat dihapus',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Tempat dipindah ke hari lain',
|
||||
'undo.lock': 'Kunci tempat diubah',
|
||||
'undo.importGpx': 'Impor GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Seleziona almeno un tipo.',
|
||||
'places.selectionCount': '{count} selezionato/i',
|
||||
'places.deleteSelected': 'Elimina selezionati',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
|
||||
'places.urlResolved': "Luogo importato dall'URL",
|
||||
'places.importList': 'Importa lista',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Percorso ottimizzato',
|
||||
'undo.deletePlace': 'Luogo eliminato',
|
||||
'undo.deletePlaces': 'Luoghi eliminati',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Luogo spostato in altro giorno',
|
||||
'undo.lock': 'Blocco luogo modificato',
|
||||
'undo.importGpx': 'Importazione GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': '少なくとも1つ選択してください。',
|
||||
'places.selectionCount': '{count} 件選択中',
|
||||
'places.deleteSelected': '選択を削除',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': 'KMZ/KMLから {count} 件の場所をインポートしました',
|
||||
'places.urlResolved': 'URLから場所をインポートしました',
|
||||
'places.importList': 'リストをインポート',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'ルートを最適化',
|
||||
'undo.deletePlace': '場所を削除',
|
||||
'undo.deletePlaces': '場所を削除',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': '場所を別の日に移動',
|
||||
'undo.lock': '場所のロックを切り替え',
|
||||
'undo.importGpx': 'GPXをインポート',
|
||||
|
||||
@@ -23,6 +23,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': '가져올 유형을 하나 이상 선택하세요.',
|
||||
'places.selectionCount': '{count}개 선택됨',
|
||||
'places.deleteSelected': '선택 항목 삭제',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': 'KMZ/KML에서 {count}개 장소를 가져왔습니다',
|
||||
'places.urlResolved': 'URL에서 장소를 가져왔습니다',
|
||||
'places.importList': '목록 가져오기',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': '경로가 최적화되었습니다',
|
||||
'undo.deletePlace': '장소가 삭제되었습니다',
|
||||
'undo.deletePlaces': '장소들이 삭제되었습니다',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': '장소가 다른 날로 이동되었습니다',
|
||||
'undo.lock': '장소 잠금이 변경되었습니다',
|
||||
'undo.importGpx': 'GPX 가져오기',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Selecteer minstens één type.',
|
||||
'places.selectionCount': '{count} geselecteerd',
|
||||
'places.deleteSelected': 'Selectie verwijderen',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.importList': 'Lijst importeren',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.deletePlaces': 'Plaatsen verwijderd',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Wybierz co najmniej jeden typ.',
|
||||
'places.selectionCount': '{count} zaznaczono',
|
||||
'places.deleteSelected': 'Usuń wybrane',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
|
||||
'places.urlResolved': 'Miejsce zaimportowane z URL',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Trasa zoptymalizowana',
|
||||
'undo.deletePlace': 'Miejsce usunięte',
|
||||
'undo.deletePlaces': 'Miejsca usunięte',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Miejsce przeniesione',
|
||||
'undo.lock': 'Blokada przełączona',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Выберите хотя бы один тип.',
|
||||
'places.selectionCount': '{count} выбрано',
|
||||
'places.deleteSelected': 'Удалить выбранные',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.importList': 'Импорт списка',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.deletePlaces': 'Места удалены',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Välj minst en typ att importera.',
|
||||
'places.selectionCount': '{count} markerade',
|
||||
'places.deleteSelected': 'Ta bort det markerade',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} platser som importerats från KMZ/KML',
|
||||
'places.urlResolved': 'Plats importerad från URL',
|
||||
'places.importList': 'Importera lista',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Rutt optimerad',
|
||||
'undo.deletePlace': 'Plats borttagen',
|
||||
'undo.deletePlaces': 'Platser borttagna',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Plats har flyttats till en annan dag',
|
||||
'undo.lock': 'Plats lås växlad',
|
||||
'undo.importGpx': 'GPX importering',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'İçe aktarılacak en az bir tür seçin.',
|
||||
'places.selectionCount': '{count} seçildi',
|
||||
'places.deleteSelected': 'Seçileni sil',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': "KMZ/KML'den {count} yer içe Aktarıldı",
|
||||
'places.urlResolved': "URL'den içe aktarılan Yer",
|
||||
'places.importList': 'Liste İçe Aktarma',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Rota optimize edildi',
|
||||
'undo.deletePlace': 'Yer silindi',
|
||||
'undo.deletePlaces': 'Yerler silindi',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Yer başka güne taşındı',
|
||||
'undo.lock': 'Yer kilidi değiştirildi',
|
||||
'undo.importGpx': 'GPX içe aktarma',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Виберіть щонайменше один тип.',
|
||||
'places.selectionCount': '{count} вибрано',
|
||||
'places.deleteSelected': 'Видалити вибране',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} місць імпортовано з KMZ/KML',
|
||||
'places.urlResolved': 'Місце імпортовано з URL',
|
||||
'places.importList': 'Імпорт списку',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Маршрут оптимізовано',
|
||||
'undo.deletePlace': 'Місце видалено',
|
||||
'undo.deletePlaces': 'Місця видалено',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Місце переміщено в інший день',
|
||||
'undo.lock': 'Блокування місця змінено',
|
||||
'undo.importGpx': 'Імпорт GPX',
|
||||
|
||||
@@ -24,6 +24,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': 'Chọn ít nhất một loại để nhập.',
|
||||
'places.selectionCount': '{count} đã chọn',
|
||||
'places.deleteSelected': 'Xóa đã chọn',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '{count} địa điểm được nhập từ KMZ/KML',
|
||||
'places.urlResolved': 'Địa điểm được nhập từ URL',
|
||||
'places.importList': 'Nhập danh sách',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': 'Tuyến đường được tối ưu hóa',
|
||||
'undo.deletePlace': 'Địa điểm đã bị xóa',
|
||||
'undo.deletePlaces': 'Địa điểm đã bị xóa',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': 'Địa điểm đã chuyển sang ngày khác',
|
||||
'undo.lock': 'Đã bật khóa địa điểm',
|
||||
'undo.importGpx': 'GPX nhập khẩu',
|
||||
|
||||
@@ -23,6 +23,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': '請至少選擇一種類型。',
|
||||
'places.selectionCount': '已選 {count} 項',
|
||||
'places.deleteSelected': '刪除所選',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
|
||||
'places.urlResolved': '已從 URL 匯入地點',
|
||||
'places.importList': '列表匯入',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': '路線已最佳化',
|
||||
'undo.deletePlace': '地點已刪除',
|
||||
'undo.deletePlaces': '地點已刪除',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': '地點已移至另一天',
|
||||
'undo.lock': '地點鎖定已切換',
|
||||
'undo.importGpx': 'GPX 匯入',
|
||||
|
||||
@@ -23,6 +23,8 @@ const places: TranslationStrings = {
|
||||
'places.kmlImportNoneSelected': '请至少选择一种类型。',
|
||||
'places.selectionCount': '已选 {count} 项',
|
||||
'places.deleteSelected': '删除所选',
|
||||
'places.changeCategory': "Change category",
|
||||
'places.categoryChanged': "{count} places updated",
|
||||
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.importList': '列表导入',
|
||||
|
||||
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.deletePlaces': '地点已删除',
|
||||
'undo.changeCategory': "Category changed",
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
|
||||
@@ -113,6 +113,14 @@ export const placeBulkDeleteRequestSchema = z.object({
|
||||
});
|
||||
export type PlaceBulkDeleteRequest = z.infer<typeof placeBulkDeleteRequestSchema>;
|
||||
|
||||
export const placeBulkUpdateRequestSchema = z.object({
|
||||
ids: z.array(z.number()).min(1),
|
||||
// null clears the category ("No category"); a number sets it. Optional so the
|
||||
// field can be omitted, but the endpoint requires it to be present to act.
|
||||
category_id: z.number().nullable().optional(),
|
||||
});
|
||||
export type PlaceBulkUpdateRequest = z.infer<typeof placeBulkUpdateRequestSchema>;
|
||||
|
||||
export const placeImportListRequestSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
// Opt-in: enrich imported places via the Places API (#886). Requires a Google
|
||||
|
||||
Reference in New Issue
Block a user