Merge branch 'dev' into test
This commit is contained in:
@@ -14,3 +14,45 @@ export async function getAuthUrl(url: string, purpose: string): Promise<string>
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-based image fetching (Safari-safe, no ephemeral tokens needed) ────
|
||||
|
||||
const MAX_CONCURRENT = 6
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function dequeue() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
active++
|
||||
queue.shift()!()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearImageQueue() {
|
||||
queue.length = 0
|
||||
}
|
||||
|
||||
export async function fetchImageAsBlob(url: string): Promise<string> {
|
||||
if (!url) return ''
|
||||
return new Promise<string>((resolve) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) { resolve(''); return }
|
||||
const blob = await resp.blob()
|
||||
resolve(URL.createObjectURL(blob))
|
||||
} catch {
|
||||
resolve('')
|
||||
} finally {
|
||||
active--
|
||||
dequeue()
|
||||
}
|
||||
}
|
||||
if (active < MAX_CONCURRENT) {
|
||||
active++
|
||||
run()
|
||||
} else {
|
||||
queue.push(run)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -489,7 +489,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
|
||||
const d = currencyDecimals(currency)
|
||||
const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
|
||||
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) }
|
||||
const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
|
||||
const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
|
||||
const rows = [header.join(sep)]
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) {
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPi
|
||||
import apiClient, { addonsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { getAuthUrl, fetchImageAsBlob, clearImageQueue } from '../../api/authUrl'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface PhotoProvider {
|
||||
@@ -16,8 +16,13 @@ interface PhotoProvider {
|
||||
function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||
const [src, setSrc] = useState('')
|
||||
useEffect(() => {
|
||||
getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {})
|
||||
}, [baseUrl, provider])
|
||||
let revoke = ''
|
||||
fetchImageAsBlob(baseUrl).then(blobUrl => {
|
||||
revoke = blobUrl
|
||||
setSrc(blobUrl)
|
||||
})
|
||||
return () => { if (revoke) URL.revokeObjectURL(revoke) }
|
||||
}, [baseUrl])
|
||||
return src ? <img src={src} alt="" loading={loading} style={style} /> : null
|
||||
}
|
||||
|
||||
@@ -296,6 +301,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
shared: true,
|
||||
})
|
||||
setShowPicker(false)
|
||||
clearImageQueue()
|
||||
loadInitial()
|
||||
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||
}
|
||||
@@ -500,7 +506,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
</h3>
|
||||
<ProviderTabs />
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button onClick={() => setShowPicker(false)}
|
||||
<button onClick={() => { clearImageQueue(); setShowPicker(false) }}
|
||||
style={{ padding: '7px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
@@ -769,9 +775,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
|
||||
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
|
||||
setLightboxOriginalSrc('')
|
||||
getAuthUrl(`/api/integrations/${photo.provider}/assets/${photo.asset_id}/original?userId=${photo.user_id}`, photo.provider).then(setLightboxOriginalSrc).catch(() => {})
|
||||
fetchImageAsBlob(`/api/integrations/immich/assets/${photo.immich_asset_id}/original?userId=${photo.user_id}`).then(setLightboxOriginalSrc)
|
||||
setLightboxInfoLoading(true)
|
||||
apiClient.get(`/integrations/${photo.provider}/assets/${photo.asset_id}/info?userId=${photo.user_id}`)
|
||||
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||
@@ -879,12 +886,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightboxId && lightboxUserId && (
|
||||
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
<div onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', inset: 0, zIndex: 100,
|
||||
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||
<button onClick={() => { if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc); setLightboxOriginalSrc(''); setLightboxId(null); setLightboxUserId(null) }}
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||
|
||||
@@ -61,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
|
||||
|
||||
function shortDate(d, locale) {
|
||||
if (!d) return ''
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function longDateRange(days, locale) {
|
||||
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
|
||||
if (!dd.length) return null
|
||||
const f = new Date(dd[0].date + 'T00:00:00')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
|
||||
const f = new Date(dd[0].date + 'T00:00:00Z')
|
||||
const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z')
|
||||
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}`
|
||||
}
|
||||
|
||||
function dayCost(assignments, dayId, locale) {
|
||||
|
||||
@@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -154,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(
|
||||
getLocaleForLanguage(language),
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
{ weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' }
|
||||
) : null
|
||||
|
||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||
@@ -445,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
@@ -457,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -743,7 +743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')}
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
|
||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
||||
</div>
|
||||
)}
|
||||
@@ -1671,7 +1671,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{res.reservation_time?.includes('T')
|
||||
? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
: res.reservation_time
|
||||
? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
: ''
|
||||
}
|
||||
{res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`}
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function PlaceInspector({
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
|
||||
@@ -424,7 +424,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', flexShrink: 0 }}>{i + 1}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)' }}>{day.title || t('dayplan.dayN', { n: i + 1 })}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <Check size={14} color="var(--text-faint)" />}
|
||||
</button>
|
||||
|
||||
@@ -572,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
const dateOnly = str.includes('T') ? str.split('T')[0] : str
|
||||
return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
|
||||
@@ -197,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00Z')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00Z')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
const newEnd = new Date(value + 'T00:00:00')
|
||||
const newEnd = new Date(value + 'T00:00:00Z')
|
||||
newEnd.setDate(newEnd.getDate() + duration)
|
||||
next.end_date = newEnd.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
@@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record<str
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string, weekendDays: number[] = [0, 6]): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return weekendDays.includes(d.getDay())
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return weekendDays.includes(d.getUTCDay())
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getUTCDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
@@ -123,8 +123,8 @@ export function daysInMonth(year: number, month: number): number {
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, locale?: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
const d = new Date(dateStr + 'T00:00:00Z')
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
|
||||
@@ -21,9 +21,9 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||
const parsed = value ? new Date(value + 'T00:00:00Z') : null
|
||||
const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -36,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
|
||||
if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) }
|
||||
}, [open])
|
||||
|
||||
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
|
||||
@@ -47,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
|
||||
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||
const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
@@ -57,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||
const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null
|
||||
const today = new Date()
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
|
||||
@@ -598,7 +598,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'خطط وأدر أيام الإجازة',
|
||||
'vacay.settings': 'الإعدادات',
|
||||
'vacay.year': 'السنة',
|
||||
'vacay.addYear': 'إضافة سنة',
|
||||
'vacay.addYear': 'إضافة السنة التالية',
|
||||
'vacay.addPrevYear': 'إضافة السنة السابقة',
|
||||
'vacay.removeYear': 'إزالة السنة',
|
||||
'vacay.removeYearConfirm': 'إزالة {year}؟',
|
||||
'vacay.removeYearHint': 'سيتم حذف كل إدخالات الإجازات والعطل الخاصة بهذه السنة نهائيًا.',
|
||||
|
||||
@@ -579,7 +579,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Planeje e gerencie dias de férias',
|
||||
'vacay.settings': 'Configurações',
|
||||
'vacay.year': 'Ano',
|
||||
'vacay.addYear': 'Adicionar ano',
|
||||
'vacay.addYear': 'Adicionar próximo ano',
|
||||
'vacay.addPrevYear': 'Adicionar ano anterior',
|
||||
'vacay.removeYear': 'Remover ano',
|
||||
'vacay.removeYearConfirm': 'Remover {year}?',
|
||||
'vacay.removeYearHint': 'Todas as entradas de férias e feriados da empresa deste ano serão excluídas permanentemente.',
|
||||
|
||||
@@ -597,7 +597,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Plánování a správa dovolené',
|
||||
'vacay.settings': 'Nastavení',
|
||||
'vacay.year': 'Rok',
|
||||
'vacay.addYear': 'Přidat rok',
|
||||
'vacay.addYear': 'Přidat následující rok',
|
||||
'vacay.addPrevYear': 'Přidat předchozí rok',
|
||||
'vacay.removeYear': 'Odebrat rok',
|
||||
'vacay.removeYearConfirm': 'Odebrat rok {year}?',
|
||||
'vacay.removeYearHint': 'Všechny záznamy o dovolené a firemní svátky pro tento rok budou trvale smazány.',
|
||||
|
||||
@@ -595,7 +595,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Urlaubstage planen und verwalten',
|
||||
'vacay.settings': 'Einstellungen',
|
||||
'vacay.year': 'Jahr',
|
||||
'vacay.addYear': 'Jahr hinzufügen',
|
||||
'vacay.addYear': 'Nächstes Jahr hinzufügen',
|
||||
'vacay.addPrevYear': 'Vorheriges Jahr hinzufügen',
|
||||
'vacay.removeYear': 'Jahr entfernen',
|
||||
'vacay.removeYearConfirm': '{year} entfernen?',
|
||||
'vacay.removeYearHint': 'Alle Urlaubseinträge und Betriebsferien für dieses Jahr werden unwiderruflich gelöscht.',
|
||||
|
||||
@@ -592,7 +592,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Plan and manage vacation days',
|
||||
'vacay.settings': 'Settings',
|
||||
'vacay.year': 'Year',
|
||||
'vacay.addYear': 'Add year',
|
||||
'vacay.addYear': 'Add next year',
|
||||
'vacay.addPrevYear': 'Add previous year',
|
||||
'vacay.removeYear': 'Remove year',
|
||||
'vacay.removeYearConfirm': 'Remove {year}?',
|
||||
'vacay.removeYearHint': 'All vacation entries and company holidays for this year will be permanently deleted.',
|
||||
|
||||
@@ -572,7 +572,8 @@ const es: Record<string, string> = {
|
||||
'vacay.subtitle': 'Planifica y gestiona días de vacaciones',
|
||||
'vacay.settings': 'Ajustes',
|
||||
'vacay.year': 'Año',
|
||||
'vacay.addYear': 'Añadir año',
|
||||
'vacay.addYear': 'Añadir año siguiente',
|
||||
'vacay.addPrevYear': 'Añadir año anterior',
|
||||
'vacay.removeYear': 'Eliminar año',
|
||||
'vacay.removeYearConfirm': '¿Eliminar {year}?',
|
||||
'vacay.removeYearHint': 'Todas las vacaciones y festivos de empresa de este año se borrarán permanentemente.',
|
||||
|
||||
@@ -594,7 +594,8 @@ const fr: Record<string, string> = {
|
||||
'vacay.subtitle': 'Planifiez et gérez vos jours de congés',
|
||||
'vacay.settings': 'Paramètres',
|
||||
'vacay.year': 'Année',
|
||||
'vacay.addYear': 'Ajouter une année',
|
||||
'vacay.addYear': 'Ajouter l\'année suivante',
|
||||
'vacay.addPrevYear': 'Ajouter l\'année précédente',
|
||||
'vacay.removeYear': 'Supprimer l\'année',
|
||||
'vacay.removeYearConfirm': 'Supprimer {year} ?',
|
||||
'vacay.removeYearHint': 'Toutes les entrées de vacances et jours fériés d\'entreprise de cette année seront définitivement supprimés.',
|
||||
|
||||
@@ -595,7 +595,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Szabadságnapok tervezése és kezelése',
|
||||
'vacay.settings': 'Beállítások',
|
||||
'vacay.year': 'Év',
|
||||
'vacay.addYear': 'Év hozzáadása',
|
||||
'vacay.addYear': 'Következő év hozzáadása',
|
||||
'vacay.addPrevYear': 'Előző év hozzáadása',
|
||||
'vacay.removeYear': 'Év eltávolítása',
|
||||
'vacay.removeYearConfirm': '{year} eltávolítása?',
|
||||
'vacay.removeYearHint': 'Az adott év összes szabadság-bejegyzése és céges szabadnapja véglegesen törlődik.',
|
||||
|
||||
@@ -595,7 +595,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Pianifica e gestisci i giorni di ferie',
|
||||
'vacay.settings': 'Impostazioni',
|
||||
'vacay.year': 'Anno',
|
||||
'vacay.addYear': 'Aggiungi anno',
|
||||
'vacay.addYear': 'Aggiungi anno successivo',
|
||||
'vacay.addPrevYear': 'Aggiungi anno precedente',
|
||||
'vacay.removeYear': 'Rimuovi anno',
|
||||
'vacay.removeYearConfirm': 'Rimuovere {year}?',
|
||||
'vacay.removeYearHint': 'Tutte le voci delle ferie e le ferie aziendali di questo anno verranno eliminate in modo permanente.',
|
||||
|
||||
@@ -594,7 +594,8 @@ const nl: Record<string, string> = {
|
||||
'vacay.subtitle': 'Plan en beheer vakantiedagen',
|
||||
'vacay.settings': 'Instellingen',
|
||||
'vacay.year': 'Jaar',
|
||||
'vacay.addYear': 'Jaar toevoegen',
|
||||
'vacay.addYear': 'Volgend jaar toevoegen',
|
||||
'vacay.addPrevYear': 'Vorig jaar toevoegen',
|
||||
'vacay.removeYear': 'Jaar verwijderen',
|
||||
'vacay.removeYearConfirm': '{year} verwijderen?',
|
||||
'vacay.removeYearHint': 'Alle vakantie-invoeren en bedrijfsvakanties voor dit jaar worden permanent verwijderd.',
|
||||
|
||||
@@ -558,7 +558,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'vacay.subtitle': 'Planuj i zarządzaj dniami urlopu',
|
||||
'vacay.settings': 'Ustawienia',
|
||||
'vacay.year': 'Rok',
|
||||
'vacay.addYear': 'Dodaj rok',
|
||||
'vacay.addYear': 'Dodaj następny rok',
|
||||
'vacay.addPrevYear': 'Dodaj poprzedni rok',
|
||||
'vacay.removeYear': 'Usuń rok',
|
||||
'vacay.removeYearConfirm': 'Usunąć {year}?',
|
||||
'vacay.removeYearHint': 'Wszystkie wpisy dotyczące urlopów oraz dni wolnych w tym roku zostaną trwale usunięte.',
|
||||
|
||||
@@ -594,7 +594,8 @@ const ru: Record<string, string> = {
|
||||
'vacay.subtitle': 'Планируйте и управляйте днями отпуска',
|
||||
'vacay.settings': 'Настройки',
|
||||
'vacay.year': 'Год',
|
||||
'vacay.addYear': 'Добавить год',
|
||||
'vacay.addYear': 'Добавить следующий год',
|
||||
'vacay.addPrevYear': 'Добавить предыдущий год',
|
||||
'vacay.removeYear': 'Удалить год',
|
||||
'vacay.removeYearConfirm': 'Удалить {year}?',
|
||||
'vacay.removeYearHint': 'Все записи об отпуске и корпоративные выходные за этот год будут безвозвратно удалены.',
|
||||
|
||||
@@ -594,7 +594,8 @@ const zh: Record<string, string> = {
|
||||
'vacay.subtitle': '规划和管理假期',
|
||||
'vacay.settings': '设置',
|
||||
'vacay.year': '年份',
|
||||
'vacay.addYear': '添加年份',
|
||||
'vacay.addYear': '添加下一年',
|
||||
'vacay.addPrevYear': '添加上一年',
|
||||
'vacay.removeYear': '移除年份',
|
||||
'vacay.removeYearConfirm': '移除 {year}?',
|
||||
'vacay.removeYearHint': '该年度所有假期记录和公司假日将被永久删除。',
|
||||
|
||||
@@ -59,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null {
|
||||
|
||||
function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
|
||||
|
||||
@@ -106,7 +106,7 @@ export default function SharedTripPage() {
|
||||
{(trip.start_date || trip.end_date) && (
|
||||
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 14px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 500, opacity: 0.8 }}>
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')}
|
||||
{[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).join(' — ')}
|
||||
</span>
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.4 }}>·</span>}
|
||||
{days?.length > 0 && <span style={{ fontSize: 11, opacity: 0.5 }}>{days.length} {t('shared.days')}</span>}
|
||||
@@ -199,7 +199,7 @@ export default function SharedTripPage() {
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: selectedDay === day.id ? '#111827' : '#f3f4f6', color: selectedDay === day.id ? 'white' : '#6b7280', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, fontWeight: 700, flexShrink: 0 }}>{di + 1}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#111827' }}>{day.title || `Day ${day.day_number}`}</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>}
|
||||
{day.date && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}</div>}
|
||||
</div>
|
||||
{dayAccs.map((acc: any) => (
|
||||
<span key={acc.id} style={{ fontSize: 9, padding: '2px 6px', borderRadius: 4, background: '#f3f4f6', color: '#6b7280', display: 'flex', alignItems: 'center', gap: 3 }}>
|
||||
@@ -274,7 +274,7 @@ export default function SharedTripPage() {
|
||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||
const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : ''
|
||||
const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : ''
|
||||
return (
|
||||
<div key={r.id} style={{ background: 'var(--bg-card, white)', borderRadius: 10, padding: '12px 16px', border: '1px solid var(--border-faint, #e5e7eb)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', background: '#f3f4f6', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
|
||||
@@ -41,11 +41,16 @@ export default function VacayPage(): React.ReactElement {
|
||||
if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) }
|
||||
}, [selectedYear])
|
||||
|
||||
const handleAddYear = () => {
|
||||
const handleAddNextYear = () => {
|
||||
const nextYear = years.length > 0 ? Math.max(...years) + 1 : new Date().getFullYear()
|
||||
addYear(nextYear)
|
||||
}
|
||||
|
||||
const handleAddPrevYear = () => {
|
||||
const prevYear = years.length > 0 ? Math.min(...years) - 1 : new Date().getFullYear()
|
||||
addYear(prevYear)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ background: 'var(--bg-primary)' }}>
|
||||
@@ -62,20 +67,27 @@ export default function VacayPage(): React.ReactElement {
|
||||
<>
|
||||
{/* Year Selector */}
|
||||
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.year')}</span>
|
||||
<button onClick={handleAddYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={handleAddPrevYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addPrevYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx > 0) setSelectedYear(years[idx - 1]) }} disabled={years.indexOf(selectedYear) <= 0} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronLeft size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xl font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{selectedYear}</span>
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 lg:p-1 p-2 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => { const idx = years.indexOf(selectedYear); if (idx < years.length - 1) setSelectedYear(years[idx + 1]) }} disabled={years.indexOf(selectedYear) >= years.length - 1} className="p-1 rounded-lg disabled:opacity-20 transition-colors" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<ChevronRight size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
<button onClick={handleAddNextYear} className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }} title={t('vacay.addYear')}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{years.map(y => (
|
||||
|
||||
@@ -222,7 +222,14 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
||||
|
||||
removeYear: async (year: number) => {
|
||||
const data = await api.removeYear(year)
|
||||
set({ years: data.years })
|
||||
const updates: Partial<VacayState> = { years: data.years }
|
||||
if (get().selectedYear === year) {
|
||||
updates.selectedYear = data.years.length > 0
|
||||
? data.years[data.years.length - 1]
|
||||
: new Date().getFullYear()
|
||||
}
|
||||
set(updates)
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
loadEntries: async (year?: number) => {
|
||||
@@ -240,6 +247,7 @@ export const useVacayStore = create<VacayState>((set, get) => ({
|
||||
toggleCompanyHoliday: async (date: string) => {
|
||||
await api.toggleCompanyHoliday(date)
|
||||
await get().loadEntries()
|
||||
await get().loadStats()
|
||||
},
|
||||
|
||||
loadStats: async (year?: number) => {
|
||||
|
||||
@@ -10,9 +10,9 @@ export function formatDate(dateStr: string | null | undefined, locale: string, t
|
||||
if (!dateStr) return null
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
timeZone: timeZone || 'UTC',
|
||||
}
|
||||
if (timeZone) opts.timeZone = timeZone
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts)
|
||||
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts)
|
||||
}
|
||||
|
||||
export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {
|
||||
|
||||
Reference in New Issue
Block a user