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:
Maurice
2026-06-29 23:01:16 +02:00
committed by Maurice
parent 42b45dcd82
commit 50609b078a
58 changed files with 356 additions and 5 deletions
+3
View File
@@ -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,
+3 -2
View File
@@ -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>
+27 -1
View File
@@ -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,
+27
View File
@@ -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
},
}
+30
View File
@@ -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);
+4
View File
@@ -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>);
+2
View File
@@ -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': 'استيراد قائمة',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': 'تم تحسين المسار',
'undo.deletePlace': 'تم حذف المكان',
'undo.deletePlaces': 'تم حذف الأماكن',
'undo.changeCategory': "Category changed",
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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': 'Εισαγωγή Λίστας',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': 'Η διαδρομή βελτιστοποιήθηκε',
'undo.deletePlace': 'Η τοποθεσία διαγράφηκε',
'undo.deletePlaces': 'Οι τοποθεσίες διαγράφηκαν',
'undo.changeCategory': "Category changed",
'undo.moveDay': 'Η τοποθεσία μεταφέρθηκε σε άλλη ημέρα',
'undo.lock': 'Το κλείδωμα τοποθεσίας εναλλάχθηκε',
'undo.importGpx': 'Εισαγωγή GPX',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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': 'リストをインポート',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': 'ルートを最適化',
'undo.deletePlace': '場所を削除',
'undo.deletePlaces': '場所を削除',
'undo.changeCategory': "Category changed",
'undo.moveDay': '場所を別の日に移動',
'undo.lock': '場所のロックを切り替え',
'undo.importGpx': 'GPXをインポート',
+2
View File
@@ -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': '목록 가져오기',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': '경로가 최적화되었습니다',
'undo.deletePlace': '장소가 삭제되었습니다',
'undo.deletePlaces': '장소들이 삭제되었습니다',
'undo.changeCategory': "Category changed",
'undo.moveDay': '장소가 다른 날로 이동되었습니다',
'undo.lock': '장소 잠금이 변경되었습니다',
'undo.importGpx': 'GPX 가져오기',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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}',
+1
View File
@@ -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',
+2
View File
@@ -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': 'Импорт списка',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': 'Маршрут оптимизирован',
'undo.deletePlace': 'Место удалено',
'undo.deletePlaces': 'Места удалены',
'undo.changeCategory': "Category changed",
'undo.moveDay': 'Место перемещено в другой день',
'undo.lock': 'Блокировка места изменена',
'undo.importGpx': 'Импорт GPX',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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': 'Імпорт списку',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': 'Маршрут оптимізовано',
'undo.deletePlace': 'Місце видалено',
'undo.deletePlaces': 'Місця видалено',
'undo.changeCategory': "Category changed",
'undo.moveDay': 'Місце переміщено в інший день',
'undo.lock': 'Блокування місця змінено',
'undo.importGpx': 'Імпорт GPX',
+2
View File
@@ -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',
+1
View File
@@ -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',
+2
View File
@@ -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': '列表匯入',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': '路線已最佳化',
'undo.deletePlace': '地點已刪除',
'undo.deletePlaces': '地點已刪除',
'undo.changeCategory': "Category changed",
'undo.moveDay': '地點已移至另一天',
'undo.lock': '地點鎖定已切換',
'undo.importGpx': 'GPX 匯入',
+2
View File
@@ -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': '列表导入',
+1
View File
@@ -9,6 +9,7 @@ const undo: TranslationStrings = {
'undo.optimize': '路线已优化',
'undo.deletePlace': '地点已删除',
'undo.deletePlaces': '地点已删除',
'undo.changeCategory': "Category changed",
'undo.moveDay': '地点已移至另一天',
'undo.lock': '地点锁定已切换',
'undo.importGpx': 'GPX 导入',
+8
View File
@@ -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