diff --git a/client/src/components/Vacay/VacayCalendar.tsx b/client/src/components/Vacay/VacayCalendar.tsx index 0dcf7511d..be51a70b3 100644 --- a/client/src/components/Vacay/VacayCalendar.tsx +++ b/client/src/components/Vacay/VacayCalendar.tsx @@ -8,7 +8,7 @@ import { Building2, MousePointer2 } from 'lucide-react' export default function VacayCalendar() { const { t } = useTranslation() - const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore() + const { selectedYear, entries, companyHolidays, foreignEntries, visibleGranterIds, toggleEntry, toggleCompanyHoliday, plan, holidays } = useVacayStore() const [companyMode, setCompanyMode] = useState(false) const [tripDates, setTripDates] = useState>(new Set()) @@ -36,25 +36,33 @@ export default function VacayCalendar() { }, [selectedYear]) const companyHolidaySet = useMemo(() => { - const s = new Set() + const s = new Set() companyHolidays.forEach(h => s.add(h.date)) return s }, [companyHolidays]) const entryMap = useMemo(() => { - const map = {} + const map: Record = {} entries.forEach(e => { if (!map[e.date]) map[e.date] = [] map[e.date].push(e) }) + foreignEntries + .filter(e => visibleGranterIds.includes(e.user_id)) + .forEach(e => { + if (!map[e.date]) map[e.date] = [] + if (!map[e.date].some(x => x.user_id === e.user_id)) { + map[e.date].push(e) + } + }) return map - }, [entries]) + }, [entries, foreignEntries, visibleGranterIds]) const blockWeekends = plan?.block_weekends !== false const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6] const companyHolidaysEnabled = plan?.company_holidays_enabled !== false - const handleCellClick = useCallback(async (dateStr) => { + const handleCellClick = useCallback(async (dateStr: string) => { if (companyMode) { if (!companyHolidaysEnabled) return await toggleCompanyHoliday(dateStr) @@ -63,10 +71,8 @@ export default function VacayCalendar() { if (holidays[dateStr]) return if (blockWeekends && isWeekend(dateStr, weekendDays)) return if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return - await toggleEntry(dateStr, selectedUserId || undefined) - }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId]) - - const selectedUser = users.find(u => u.id === selectedUserId) + await toggleEntry(dateStr) + }, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled]) return (
@@ -102,8 +108,7 @@ export default function VacayCalendar() { border: companyMode ? '1px solid var(--border-primary)' : '1px solid transparent', }}> - {selectedUser && } - {selectedUser ? selectedUser.username : t('vacay.modeVacation')} + {t('vacay.modeVacation')} {companyHolidaysEnabled && (
- {users.map(u => { - const isSelected = selectedUserId === u.id + {/* Myself — always visible, color picker */} +
+
+ + {hasOthers &&
} + + {/* Connected users — bidirectional (toggle visibility + disconnect on hover) */} + {connectedUsers.map(u => { + const isVisible = visibleGranterIds.includes(u.id) return ( -
{ if (isFused) setSelectedUserId(u.id) }} - className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all" +
toggleGranterVisibility(u.id)} + className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg cursor-pointer transition-all group" style={{ - background: isSelected ? 'var(--bg-hover)' : 'transparent', - border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent', - cursor: isFused ? 'pointer' : 'default', - }}> - + {isVisible + ? + : + }
) })} - {/* Pending invites */} - {pendingInvites.map(inv => ( -
+ {/* Pending incoming invites */} + {pendingIncoming.map(inv => ( +
+ + + {inv.granter_username} + +
+ + +
+
+ ))} + + {/* Pending outgoing (I invited someone, waiting) */} + {pendingOutgoing.map(inv => ( +
- {inv.username} ({t('vacay.pending')}) + {inv.viewer_username} + ({t('vacay.pending')}) -
))}
- {/* Invite Modal — Portal to body to avoid z-index issues */} + {/* Grant-access Invite Modal */} {showInvite && ReactDOM.createPortal( -
setShowInvite(false)}> -
e.stopPropagation()}> +
setShowInvite(false)} + > +
e.stopPropagation()} + >
-

{t('vacay.inviteUser')}

+

{t('vacay.grantAccess')}

-

{t('vacay.inviteHint')}

+

{t('vacay.grantAccessHint')}

{availableUsers.length === 0 ? (

{t('vacay.noUsersAvailable')}

) : ( @@ -145,13 +211,19 @@ export default function VacayPersons() { /> )}
- - @@ -162,24 +234,33 @@ export default function VacayPersons() { document.body )} - {/* Color Picker Modal — Portal to body */} + {/* Color Picker Modal */} {showColorPicker && ReactDOM.createPortal( -
{ setShowColorPicker(false); setColorEditUserId(null) }}> -
e.stopPropagation()}> +
setShowColorPicker(false)} + > +
e.stopPropagation()} + >

{t('vacay.changeColor')}

-
{PRESET_COLORS.map(c => ( -
@@ -187,6 +268,7 @@ export default function VacayPersons() {
, document.body )} +
) } diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index 14efe606e..1ca63d14a 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -1,8 +1,7 @@ import { useState, useEffect } from 'react' -import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react' +import { type LucideIcon, CalendarOff, AlertCircle, Building2, ArrowRightLeft, Globe, Plus, Trash2, CalendarDays } from 'lucide-react' import { useVacayStore } from '../../store/vacayStore' import { getIntlLanguage, useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' import type { VacayHolidayCalendar } from '../../types' @@ -13,8 +12,7 @@ interface VacaySettingsProps { export default function VacaySettings({ onClose }: VacaySettingsProps) { const { t } = useTranslation() - const toast = useToast() - const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore() + const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar } = useVacayStore() const [countries, setCountries] = useState<{ value: string; label: string }[]>([]) const [showAddForm, setShowAddForm] = useState(false) @@ -189,42 +187,6 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { )}
- {/* Dissolve fusion */} - {isFused && ( -
-
-
-
- -
-
-

{t('vacay.dissolve')}

-

{t('vacay.dissolveHint')}

-
-
-
- {users.map(u => ( -
- - {u.username} -
- ))} -
-
- -
-
-
- )}
) } diff --git a/client/src/components/Vacay/VacayStats.tsx b/client/src/components/Vacay/VacayStats.tsx index 99d41cff8..c544b4b57 100644 --- a/client/src/components/Vacay/VacayStats.tsx +++ b/client/src/components/Vacay/VacayStats.tsx @@ -5,16 +5,9 @@ import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import type { VacayStat } from '../../types' -interface VacayStatExtended extends VacayStat { - username: string - avatar_url: string | null - color: string | null - total_available: number -} - export default function VacayStats() { const { t } = useTranslation() - const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore() + const { stats, selectedYear, loadStats, updateVacationDays } = useVacayStore() const { user: currentUser } = useAuthStore() useEffect(() => { loadStats(selectedYear) }, [selectedYear]) @@ -37,7 +30,7 @@ export default function VacayStats() { key={s.user_id} stat={s} isMe={s.user_id === currentUser?.id} - canEdit={s.user_id === currentUser?.id || isFused} + canEdit={!!s.canEdit} selectedYear={selectedYear} onSave={updateVacationDays} t={t} @@ -50,11 +43,11 @@ export default function VacayStats() { } interface StatCardProps { - stat: VacayStatExtended + stat: VacayStat isMe: boolean canEdit: boolean selectedYear: number - onSave: (userId: number, year: number, days: number) => Promise + onSave: (year: number, days: number) => Promise t: (key: string) => string } @@ -70,9 +63,8 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP const handleSave = () => { setEditing(false) - const days = parseInt(localDays) - if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) { - onSave(selectedYear, days, s.user_id) + if (!isNaN(localDays) && localDays >= 0 && localDays <= 365 && localDays !== s.vacation_days) { + onSave(selectedYear, localDays) } } @@ -107,7 +99,7 @@ function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardP setLocalDays(e.target.value)} + onChange={e => setLocalDays(parseInt(e.target.value))} onBlur={handleSave} onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }} autoFocus diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 2f93e9e7d..18af192b8 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -763,6 +763,13 @@ const de: Record = { 'vacay.fuseInfo3': 'Beide können Einträge löschen und den Urlaubsanspruch ändern.', 'vacay.fuseInfo4': 'Einstellungen wie Feiertage und Betriebsferien werden geteilt.', 'vacay.fuseInfo5': 'Die Fusion kann jederzeit von beiden Seiten aufgelöst werden. Einträge bleiben erhalten.', + 'vacay.grantAccess': 'Kalender teilen', + 'vacay.grantAccessHint': 'Lade einen TREK-Benutzer ein, um ihm Leserechte auf deinen Kalender zu geben. Er sieht deine Urlaubstage in seinem eigenen Kalender.', + 'vacay.accessInviteTitle': 'Kalender-Freigabe', + 'vacay.inviteWantsToShare': 'möchte dir seinen Kalender freigeben.', + 'vacay.accessInfo1': 'Du kannst die Urlaubstage dieser Person in deinem Kalender sehen.', + 'vacay.accessInfo2': 'Der Zugriff kann jederzeit von beiden Seiten widerrufen werden.', + 'vacay.revoke': 'Widerrufen', 'nav.myTrips': 'Meine Trips', // Atlas addon diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index da78e7c43..fd40345ed 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -785,6 +785,13 @@ const en: Record = { 'vacay.fuseInfo3': 'Both parties can delete entries and change vacation entitlements.', 'vacay.fuseInfo4': 'Settings like public holidays and company holidays are shared.', 'vacay.fuseInfo5': 'The fusion can be dissolved at any time by either party. Your entries will be preserved.', + 'vacay.grantAccess': 'Share Calendar', + 'vacay.grantAccessHint': 'Invite a TREK user to grant them read access to your calendar. They will see your vacation days in their own calendar.', + 'vacay.accessInviteTitle': 'Calendar Sharing', + 'vacay.inviteWantsToShare': 'wants to share their calendar with you.', + 'vacay.accessInfo1': 'You can see this person\'s vacation days in your calendar.', + 'vacay.accessInfo2': 'Access can be revoked at any time by either party.', + 'vacay.revoke': 'Revoke', 'nav.myTrips': 'My Trips', // Atlas addon diff --git a/client/src/pages/VacayPage.tsx b/client/src/pages/VacayPage.tsx index b3a524ed5..5df262fdf 100644 --- a/client/src/pages/VacayPage.tsx +++ b/client/src/pages/VacayPage.tsx @@ -8,12 +8,12 @@ import VacayCalendar from '../components/Vacay/VacayCalendar' import VacayPersons from '../components/Vacay/VacayPersons' import VacayStats from '../components/Vacay/VacayStats' import VacaySettings from '../components/Vacay/VacaySettings' -import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react' +import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Eye, SlidersHorizontal, Share2 } from 'lucide-react' import Modal from '../components/shared/Modal' export default function VacayPage(): React.ReactElement { const { t } = useTranslation() - const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore() + const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loadForeignEntries, loading, pendingIncoming, acceptAccess, declineAccess, plan } = useVacayStore() const [showSettings, setShowSettings] = useState(false) const [deleteYear, setDeleteYear] = useState(null) const [showMobileSidebar, setShowMobileSidebar] = useState(false) @@ -22,13 +22,23 @@ export default function VacayPage(): React.ReactElement { // Live sync via WebSocket const handleWsMessage = useCallback((msg: { type: string }) => { - if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') { + if (msg.type === 'vacay:update') { loadPlan() loadEntries(selectedYear) loadStats(selectedYear) - if (msg.type === 'vacay:settings') loadAll() + loadForeignEntries(selectedYear) } - if (msg.type === 'vacay:invite' || msg.type === 'vacay:accepted' || msg.type === 'vacay:declined' || msg.type === 'vacay:cancelled' || msg.type === 'vacay:dissolved') { + if (msg.type === 'vacay:settings') { + loadAll() + } + if (msg.type === 'vacay:access_invite' || msg.type === 'vacay:access_cancelled') { + loadPlan() + } + if ( + msg.type === 'vacay:access_accepted' || + msg.type === 'vacay:access_declined' || + msg.type === 'vacay:access_revoked' + ) { loadAll() } }, [selectedYear]) @@ -37,8 +47,14 @@ export default function VacayPage(): React.ReactElement { addListener(handleWsMessage) return () => removeListener(handleWsMessage) }, [handleWsMessage]) + useEffect(() => { - if (selectedYear) { loadEntries(selectedYear); loadStats(selectedYear); loadHolidays(selectedYear) } + if (selectedYear) { + loadEntries(selectedYear) + loadStats(selectedYear) + loadHolidays(selectedYear) + loadForeignEntries(selectedYear) + } }, [selectedYear]) const handleAddNextYear = () => { @@ -219,49 +235,47 @@ export default function VacayPage(): React.ReactElement { -
- {/* Incoming invite — forced fullscreen modal */} - {incomingInvites.length > 0 && ReactDOM.createPortal( + {/* Incoming access invite — fullscreen modal */} + {pendingIncoming.length > 0 && ReactDOM.createPortal(
- {incomingInvites.map(inv => ( + {pendingIncoming.map(inv => (
- {inv.username?.[0]?.toUpperCase()} + {inv.granter_username?.[0]?.toUpperCase()}

- {t('vacay.inviteTitle')} + {t('vacay.accessInviteTitle')}

- {inv.username} {t('vacay.inviteWantsToFuse')} + {inv.granter_username}{' '} + {t('vacay.inviteWantsToShare')}

- - - - - + +
- -
diff --git a/client/src/store/vacayStore.ts b/client/src/store/vacayStore.ts index 8e8766488..8dc18161b 100644 --- a/client/src/store/vacayStore.ts +++ b/client/src/store/vacayStore.ts @@ -1,27 +1,17 @@ import { create } from 'zustand' import apiClient from '../api/client' import type { AxiosResponse } from 'axios' -import type { VacayPlan, VacayUser, VacayEntry, VacayStat, HolidaysMap, HolidayInfo, VacayHolidayCalendar } from '../types' +import type { VacayPlan, VacayUser, VacayEntry, VacayStat, VacayCompanyHoliday, HolidaysMap, HolidayInfo, VacayHolidayCalendar, VacayAccessInvite, VacayOutgoingInvite } from '../types' -const ax = apiClient - -interface PendingInvite { - user_id: number - username: string -} -interface IncomingInvite { - plan_id: number - owner_username: string -} +const ax = apiClient interface VacayPlanResponse { plan: VacayPlan - users: VacayUser[] - pendingInvites: PendingInvite[] - incomingInvites: IncomingInvite[] - isOwner: boolean - isFused: boolean + myColor: string + connectedUsers: VacayUser[] + pendingIncoming: VacayAccessInvite[] + pendingOutgoing: VacayOutgoingInvite[] } interface VacayYearsResponse { @@ -30,7 +20,7 @@ interface VacayYearsResponse { interface VacayEntriesResponse { entries: VacayEntry[] - companyHolidays: string[] + companyHolidays: VacayCompanyHoliday[] } interface VacayStatsResponse { @@ -48,21 +38,22 @@ interface VacayHolidayRaw { interface VacayApi { getPlan: () => Promise updatePlan: (data: Partial) => Promise<{ plan: VacayPlan }> - updateColor: (color: string, targetUserId?: number) => Promise - invite: (userId: number) => Promise - acceptInvite: (planId: number) => Promise - declineInvite: (planId: number) => Promise - cancelInvite: (userId: number) => Promise - dissolve: () => Promise - availableUsers: () => Promise<{ users: VacayUser[] }> + updateColor: (color: string) => Promise + grantAccess: (viewerId: number) => Promise + acceptAccess: (granterId: number) => Promise + declineAccess: (granterId: number) => Promise + cancelAccess: (viewerId: number) => Promise + revokeAccess: (userId: number) => Promise + availableUsersForAccess: () => Promise<{ users: VacayUser[] }> + getForeignEntries: (year: number) => Promise<{ entries: VacayEntry[] }> getYears: () => Promise addYear: (year: number) => Promise removeYear: (year: number) => Promise getEntries: (year: number) => Promise - toggleEntry: (date: string, targetUserId?: number) => Promise + toggleEntry: (date: string) => Promise toggleCompanyHoliday: (date: string) => Promise getStats: (year: number) => Promise - updateStats: (year: number, days: number, targetUserId?: number) => Promise + updateStats: (year: number, days: number) => Promise getCountries: () => Promise<{ countries: string[] }> getHolidays: (year: number, country: string) => Promise addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise<{ calendar: VacayHolidayCalendar }> @@ -73,21 +64,22 @@ interface VacayApi { const api: VacayApi = { getPlan: () => ax.get('/addons/vacay/plan').then((r: AxiosResponse) => r.data), updatePlan: (data) => ax.put('/addons/vacay/plan', data).then((r: AxiosResponse) => r.data), - updateColor: (color, targetUserId) => ax.put('/addons/vacay/color', { color, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data), - invite: (userId) => ax.post('/addons/vacay/invite', { user_id: userId }).then((r: AxiosResponse) => r.data), - acceptInvite: (planId) => ax.post('/addons/vacay/invite/accept', { plan_id: planId }).then((r: AxiosResponse) => r.data), - declineInvite: (planId) => ax.post('/addons/vacay/invite/decline', { plan_id: planId }).then((r: AxiosResponse) => r.data), - cancelInvite: (userId) => ax.post('/addons/vacay/invite/cancel', { user_id: userId }).then((r: AxiosResponse) => r.data), - dissolve: () => ax.post('/addons/vacay/dissolve').then((r: AxiosResponse) => r.data), - availableUsers: () => ax.get('/addons/vacay/available-users').then((r: AxiosResponse) => r.data), + updateColor: (color) => ax.put('/addons/vacay/color', { color }).then((r: AxiosResponse) => r.data), + grantAccess: (viewerId) => ax.post('/addons/vacay/access/grant', { viewer_id: viewerId }).then((r: AxiosResponse) => r.data), + acceptAccess: (granterId) => ax.post('/addons/vacay/access/accept', { granter_id: granterId }).then((r: AxiosResponse) => r.data), + declineAccess: (granterId) => ax.post('/addons/vacay/access/decline', { granter_id: granterId }).then((r: AxiosResponse) => r.data), + cancelAccess: (viewerId) => ax.post('/addons/vacay/access/cancel', { viewer_id: viewerId }).then((r: AxiosResponse) => r.data), + revokeAccess: (userId) => ax.delete(`/addons/vacay/access/${userId}`).then((r: AxiosResponse) => r.data), + availableUsersForAccess: () => ax.get('/addons/vacay/access/available-users').then((r: AxiosResponse) => r.data), + getForeignEntries: (year) => ax.get(`/addons/vacay/access/foreign-entries/${year}`).then((r: AxiosResponse) => r.data), getYears: () => ax.get('/addons/vacay/years').then((r: AxiosResponse) => r.data), addYear: (year) => ax.post('/addons/vacay/years', { year }).then((r: AxiosResponse) => r.data), removeYear: (year) => ax.delete(`/addons/vacay/years/${year}`).then((r: AxiosResponse) => r.data), getEntries: (year) => ax.get(`/addons/vacay/entries/${year}`).then((r: AxiosResponse) => r.data), - toggleEntry: (date, targetUserId) => ax.post('/addons/vacay/entries/toggle', { date, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data), + toggleEntry: (date) => ax.post('/addons/vacay/entries/toggle', { date }).then((r: AxiosResponse) => r.data), toggleCompanyHoliday: (date) => ax.post('/addons/vacay/entries/company-holiday', { date }).then((r: AxiosResponse) => r.data), getStats: (year) => ax.get(`/addons/vacay/stats/${year}`).then((r: AxiosResponse) => r.data), - updateStats: (year, days, targetUserId) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days, target_user_id: targetUserId }).then((r: AxiosResponse) => r.data), + updateStats: (year, days) => ax.put(`/addons/vacay/stats/${year}`, { vacation_days: days }).then((r: AxiosResponse) => r.data), getCountries: () => ax.get('/addons/vacay/holidays/countries').then((r: AxiosResponse) => r.data), getHolidays: (year, country) => ax.get(`/addons/vacay/holidays/${year}/${country}`).then((r: AxiosResponse) => r.data), addHolidayCalendar: (data) => ax.post('/addons/vacay/plan/holiday-calendars', data).then((r: AxiosResponse) => r.data), @@ -97,38 +89,39 @@ const api: VacayApi = { interface VacayState { plan: VacayPlan | null - users: VacayUser[] - pendingInvites: PendingInvite[] - incomingInvites: IncomingInvite[] - isOwner: boolean - isFused: boolean + myColor: string + connectedUsers: VacayUser[] + pendingIncoming: VacayAccessInvite[] + pendingOutgoing: VacayOutgoingInvite[] + visibleGranterIds: number[] years: number[] entries: VacayEntry[] - companyHolidays: string[] + companyHolidays: VacayCompanyHoliday[] + foreignEntries: VacayEntry[] stats: VacayStat[] selectedYear: number - selectedUserId: number | null holidays: HolidaysMap loading: boolean setSelectedYear: (year: number) => void - setSelectedUserId: (id: number | null) => void + toggleGranterVisibility: (id: number) => void loadPlan: () => Promise updatePlan: (updates: Partial) => Promise - updateColor: (color: string, targetUserId?: number) => Promise - invite: (userId: number) => Promise - acceptInvite: (planId: number) => Promise - declineInvite: (planId: number) => Promise - cancelInvite: (userId: number) => Promise - dissolve: () => Promise + updateColor: (color: string) => Promise + grantAccess: (userId: number) => Promise + acceptAccess: (granterId: number) => Promise + declineAccess: (granterId: number) => Promise + cancelAccess: (viewerId: number) => Promise + revokeAccess: (userId: number) => Promise loadYears: () => Promise addYear: (year: number) => Promise removeYear: (year: number) => Promise loadEntries: (year?: number) => Promise - toggleEntry: (date: string, targetUserId?: number) => Promise + loadForeignEntries: (year?: number) => Promise + toggleEntry: (date: string) => Promise toggleCompanyHoliday: (date: string) => Promise loadStats: (year?: number) => Promise - updateVacationDays: (year: number, days: number, targetUserId?: number) => Promise + updateVacationDays: (year: number, days: number) => Promise loadHolidays: (year?: number) => Promise addHolidayCalendar: (data: { region: string; color?: string; label?: string | null }) => Promise updateHolidayCalendar: (id: number, data: { region?: string; color?: string; label?: string | null }) => Promise @@ -138,32 +131,48 @@ interface VacayState { export const useVacayStore = create((set, get) => ({ plan: null, - users: [], - pendingInvites: [], - incomingInvites: [], - isOwner: true, - isFused: false, + myColor: '#6366f1', + connectedUsers: [], + pendingIncoming: [], + pendingOutgoing: [], + visibleGranterIds: [], years: [], entries: [], companyHolidays: [], + foreignEntries: [], stats: [], selectedYear: new Date().getFullYear(), - selectedUserId: null, holidays: {}, loading: false, setSelectedYear: (year: number) => set({ selectedYear: year }), - setSelectedUserId: (id: number | null) => set({ selectedUserId: id }), + + toggleGranterVisibility: (id: number) => { + const { visibleGranterIds } = get() + if (visibleGranterIds.includes(id)) { + set({ visibleGranterIds: visibleGranterIds.filter(gid => gid !== id) }) + } else { + set({ visibleGranterIds: [...visibleGranterIds, id] }) + } + }, loadPlan: async () => { const data = await api.getPlan() + const prev = get().visibleGranterIds + const connectedUsers: VacayUser[] = data.connectedUsers + const newConnectedIds = connectedUsers.map((u: VacayUser) => u.id) + // Keep existing visibility; auto-show newly connected users + const updated = [ + ...prev.filter(id => newConnectedIds.includes(id)), + ...newConnectedIds.filter(id => !prev.includes(id)), + ] set({ plan: data.plan, - users: data.users, - pendingInvites: data.pendingInvites, - incomingInvites: data.incomingInvites, - isOwner: data.isOwner, - isFused: data.isFused, + myColor: data.myColor, + connectedUsers, + pendingIncoming: data.pendingIncoming, + pendingOutgoing: data.pendingOutgoing, + visibleGranterIds: updated, }) }, @@ -175,34 +184,34 @@ export const useVacayStore = create((set, get) => ({ await get().loadHolidays() }, - updateColor: async (color: string, targetUserId?: number) => { - await api.updateColor(color, targetUserId) - await get().loadPlan() - await get().loadEntries() + updateColor: async (color: string) => { + await api.updateColor(color) + const year = get().selectedYear + await Promise.all([get().loadPlan(), get().loadEntries(year), get().loadStats(year)]) }, - invite: async (userId: number) => { - await api.invite(userId) + grantAccess: async (userId: number) => { + await api.grantAccess(userId) await get().loadPlan() }, - acceptInvite: async (planId: number) => { - await api.acceptInvite(planId) + acceptAccess: async (granterId: number) => { + await api.acceptAccess(granterId) await get().loadAll() }, - declineInvite: async (planId: number) => { - await api.declineInvite(planId) + declineAccess: async (granterId: number) => { + await api.declineAccess(granterId) await get().loadPlan() }, - cancelInvite: async (userId: number) => { - await api.cancelInvite(userId) + cancelAccess: async (viewerId: number) => { + await api.cancelAccess(viewerId) await get().loadPlan() }, - dissolve: async () => { - await api.dissolve() + revokeAccess: async (userId: number) => { + await api.revokeAccess(userId) await get().loadAll() }, @@ -238,8 +247,14 @@ export const useVacayStore = create((set, get) => ({ set({ entries: data.entries, companyHolidays: data.companyHolidays }) }, - toggleEntry: async (date: string, targetUserId?: number) => { - await api.toggleEntry(date, targetUserId) + loadForeignEntries: async (year?: number) => { + const y = year || get().selectedYear + const data = await api.getForeignEntries(y) + set({ foreignEntries: data.entries }) + }, + + toggleEntry: async (date: string) => { + await api.toggleEntry(date) await get().loadEntries() await get().loadStats() }, @@ -256,8 +271,8 @@ export const useVacayStore = create((set, get) => ({ set({ stats: data.stats }) }, - updateVacationDays: async (year: number, days: number, targetUserId?: number) => { - await api.updateStats(year, days, targetUserId) + updateVacationDays: async (year: number, days: number) => { + await api.updateStats(year, days) await get().loadStats(year) }, @@ -280,7 +295,7 @@ export const useVacayStore = create((set, get) => ({ data.forEach((h: VacayHolidayRaw) => { if (h.global || !h.counties || (region && h.counties.includes(region))) { if (!map[h.date]) { - map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label } + map[h.date] = { name: h.name, localName: h.localName, color: cal.color, label: cal.label } as HolidayInfo } } }) @@ -313,9 +328,12 @@ export const useVacayStore = create((set, get) => ({ await get().loadPlan() await get().loadYears() const year = get().selectedYear - await get().loadEntries(year) - await get().loadStats(year) - await get().loadHolidays(year) + await Promise.all([ + get().loadEntries(year), + get().loadStats(year), + get().loadHolidays(year), + get().loadForeignEntries(year), + ]) } finally { set({ loading: false }) } diff --git a/client/src/types.ts b/client/src/types.ts index bcaa82e67..ce301276d 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -338,17 +338,15 @@ export interface VacayPlan { carry_over_enabled: boolean company_holidays_enabled: boolean week_start?: number - name?: string - year?: number + weekend_days?: string owner_id?: number created_at?: string - updated_at?: string } export interface VacayUser { id: number username: string - color: string | null + color: string } export interface VacayEntry { @@ -359,10 +357,36 @@ export interface VacayEntry { person_name?: string } +export interface VacayCompanyHoliday { + id: number + plan_id: number + date: string + note: string +} + export interface VacayStat { user_id: number + person_name: string + person_color: string + year: number vacation_days: number + carried_over: number + total_available: number used: number + remaining: number + canEdit?: boolean +} + +export interface VacayAccessInvite { + id: number + granter_id: number + granter_username: string +} + +export interface VacayOutgoingInvite { + id: number + viewer_id: number + viewer_username: string } export interface HolidayInfo { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f614ff4d6..a301ab44b 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1435,6 +1435,73 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec("ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1"); } catch {} }, + // Migration 83: Refactor vacay fusion model to per-user plans with read-access + () => { + // Create the new read-access table + db.exec(` + CREATE TABLE IF NOT EXISTS vacay_read_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + granter_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + viewer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(granter_id, viewer_id) + ); + CREATE INDEX IF NOT EXISTS idx_vacay_read_access_viewer ON vacay_read_access(viewer_id); + CREATE INDEX IF NOT EXISTS idx_vacay_read_access_granter ON vacay_read_access(granter_id); + `); + + // Migrate accepted fusions → bidirectional read_access (both can see each other) + db.exec(` + INSERT OR IGNORE INTO vacay_read_access (granter_id, viewer_id, status) + SELECT p.owner_id, m.user_id, 'accepted' + FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id + WHERE m.status = 'accepted'; + + INSERT OR IGNORE INTO vacay_read_access (granter_id, viewer_id, status) + SELECT m.user_id, p.owner_id, 'accepted' + FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id + WHERE m.status = 'accepted'; + + INSERT OR IGNORE INTO vacay_read_access (granter_id, viewer_id, status) + SELECT p.owner_id, m.user_id, 'pending' + FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id + WHERE m.status = 'pending'; + `); + + // Move entries/years/colors back to each member's own plan + const members = db.prepare(` + SELECT m.user_id, p.id as shared_plan_id + FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id + WHERE m.status = 'accepted' + `).all() as { user_id: number; shared_plan_id: number }[]; + + for (const m of members) { + const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(m.user_id) as { id: number } | undefined; + if (!ownPlan) continue; + + // Ensure own plan has all years from the shared plan + const sharedYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(m.shared_plan_id) as { year: number }[]; + for (const { year } of sharedYears) { + db.prepare('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlan.id, year); + } + + // Move vacation entries back to own plan + db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, m.shared_plan_id, m.user_id); + + // Copy user_years from shared plan to own plan + const userYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(m.user_id, m.shared_plan_id) as { year: number; vacation_days: number; carried_over: number }[]; + for (const uy of userYears) { + db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(m.user_id, ownPlan.id, uy.year, uy.vacation_days, uy.carried_over); + } + + // Copy user color from shared plan to own plan + const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(m.user_id, m.shared_plan_id) as { color: string } | undefined; + if (colorRow) { + db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(m.user_id, ownPlan.id, colorRow.color); + } + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts index e3ad5280e..f7deda6c4 100644 --- a/server/src/routes/vacay.ts +++ b/server/src/routes/vacay.ts @@ -6,6 +6,10 @@ import * as svc from '../services/vacayService'; const router = express.Router(); router.use(authenticate); +// --------------------------------------------------------------------------- +// Plan (own plan always) +// --------------------------------------------------------------------------- + router.get('/plan', (req: Request, res: Response) => { const authReq = req as AuthRequest; res.json(svc.getPlanData(authReq.user.id)); @@ -13,8 +17,8 @@ router.get('/plan', (req: Request, res: Response) => { router.put('/plan', async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - const result = await svc.updatePlan(planId, req.body, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const result = await svc.updatePlan(plan.id, req.body, req.headers['x-socket-id'] as string); res.json(result); }); @@ -22,16 +26,16 @@ router.post('/plan/holiday-calendars', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { region, label, color, sort_order } = req.body; if (!region) return res.status(400).json({ error: 'region required' }); - const planId = svc.getActivePlanId(authReq.user.id); - const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const calendar = svc.addHolidayCalendar(plan.id, region, label, color, sort_order, req.headers['x-socket-id'] as string); res.json({ calendar }); }); router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; const id = parseInt(req.params.id); - const planId = svc.getActivePlanId(authReq.user.id); - const calendar = svc.updateHolidayCalendar(id, planId, req.body, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const calendar = svc.updateHolidayCalendar(id, plan.id, req.body, req.headers['x-socket-id'] as string); if (!calendar) return res.status(404).json({ error: 'Calendar not found' }); res.json({ calendar }); }); @@ -39,145 +43,156 @@ router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => { const authReq = req as AuthRequest; const id = parseInt(req.params.id); - const planId = svc.getActivePlanId(authReq.user.id); - const deleted = svc.deleteHolidayCalendar(id, planId, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const deleted = svc.deleteHolidayCalendar(id, plan.id, req.headers['x-socket-id'] as string); if (!deleted) return res.status(404).json({ error: 'Calendar not found' }); res.json({ success: true }); }); +// --------------------------------------------------------------------------- +// User color (own plan only) +// --------------------------------------------------------------------------- + router.put('/color', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { color, target_user_id } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = svc.getPlanUsers(planId); - if (!planUsers.find(u => u.id === userId)) { - return res.status(403).json({ error: 'User not in plan' }); - } - svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string); + const { color } = req.body; + const plan = svc.getOwnPlan(authReq.user.id); + svc.setUserColor(authReq.user.id, plan.id, color, req.headers['x-socket-id'] as string); res.json({ success: true }); }); -router.post('/invite', (req: Request, res: Response) => { +// --------------------------------------------------------------------------- +// Read-access management +// --------------------------------------------------------------------------- + +router.post('/access/grant', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { user_id } = req.body; - if (!user_id) return res.status(400).json({ error: 'user_id required' }); - const plan = svc.getActivePlan(authReq.user.id); - const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id); + const { viewer_id } = req.body; + if (!viewer_id) return res.status(400).json({ error: 'viewer_id required' }); + const result = svc.grantAccess(authReq.user.id, authReq.user.username, authReq.user.email, parseInt(viewer_id)); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); -router.post('/invite/accept', (req: Request, res: Response) => { +router.post('/access/accept', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { plan_id } = req.body; - const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); + const { granter_id } = req.body; + if (!granter_id) return res.status(400).json({ error: 'granter_id required' }); + const result = svc.acceptAccessInvite(authReq.user.id, parseInt(granter_id), req.headers['x-socket-id'] as string); if (result.error) return res.status(result.status!).json({ error: result.error }); res.json({ success: true }); }); -router.post('/invite/decline', (req: Request, res: Response) => { +router.post('/access/decline', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { plan_id } = req.body; - svc.declineInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); + const { granter_id } = req.body; + if (!granter_id) return res.status(400).json({ error: 'granter_id required' }); + svc.declineAccessInvite(authReq.user.id, parseInt(granter_id), req.headers['x-socket-id'] as string); res.json({ success: true }); }); -router.post('/invite/cancel', (req: Request, res: Response) => { +router.post('/access/cancel', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { user_id } = req.body; - const plan = svc.getActivePlan(authReq.user.id); - svc.cancelInvite(plan.id, user_id); + const { viewer_id } = req.body; + if (!viewer_id) return res.status(400).json({ error: 'viewer_id required' }); + svc.cancelAccessInvite(authReq.user.id, parseInt(viewer_id)); res.json({ success: true }); }); -router.post('/dissolve', (req: Request, res: Response) => { +router.delete('/access/:userId', (req: Request, res: Response) => { const authReq = req as AuthRequest; - svc.dissolvePlan(authReq.user.id, req.headers['x-socket-id'] as string); + const otherUserId = parseInt(req.params.userId); + svc.revokeAccess(authReq.user.id, otherUserId, req.headers['x-socket-id'] as string); res.json({ success: true }); }); -router.get('/available-users', (req: Request, res: Response) => { +router.get('/access/available-users', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - const users = svc.getAvailableUsers(authReq.user.id, planId); + const users = svc.getAvailableUsersForAccess(authReq.user.id); res.json({ users }); }); +router.get('/access/foreign-entries/:year', (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const entries = svc.getForeignEntries(authReq.user.id, req.params.year); + res.json({ entries }); +}); + +// --------------------------------------------------------------------------- +// Years +// --------------------------------------------------------------------------- + router.get('/years', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - res.json({ years: svc.listYears(planId) }); + const plan = svc.getOwnPlan(authReq.user.id); + res.json({ years: svc.listYears(plan.id) }); }); router.post('/years', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { year } = req.body; if (!year) return res.status(400).json({ error: 'Year required' }); - const planId = svc.getActivePlanId(authReq.user.id); - const years = svc.addYear(planId, year, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const years = svc.addYear(plan.id, year, req.headers['x-socket-id'] as string); res.json({ years }); }); router.delete('/years/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; const year = parseInt(req.params.year); - const planId = svc.getActivePlanId(authReq.user.id); - const years = svc.deleteYear(planId, year, req.headers['x-socket-id'] as string); + const plan = svc.getOwnPlan(authReq.user.id); + const years = svc.deleteYear(plan.id, year, req.headers['x-socket-id'] as string); res.json({ years }); }); +// --------------------------------------------------------------------------- +// Entries (own plan) +// --------------------------------------------------------------------------- + router.get('/entries/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - res.json(svc.getEntries(planId, req.params.year)); + const plan = svc.getOwnPlan(authReq.user.id); + res.json(svc.getEntries(plan.id, req.params.year)); }); router.post('/entries/toggle', (req: Request, res: Response) => { const authReq = req as AuthRequest; - const { date, target_user_id } = req.body; + const { date } = req.body; if (!date) return res.status(400).json({ error: 'date required' }); - const planId = svc.getActivePlanId(authReq.user.id); - let userId = authReq.user.id; - if (target_user_id && parseInt(target_user_id) !== authReq.user.id) { - const planUsers = svc.getPlanUsers(planId); - const tid = parseInt(target_user_id); - if (!planUsers.find(u => u.id === tid)) { - return res.status(403).json({ error: 'User not in plan' }); - } - userId = tid; - } - res.json(svc.toggleEntry(userId, planId, date, req.headers['x-socket-id'] as string)); + const plan = svc.getOwnPlan(authReq.user.id); + res.json(svc.toggleEntry(authReq.user.id, plan.id, date, req.headers['x-socket-id'] as string)); }); router.post('/entries/company-holiday', (req: Request, res: Response) => { const authReq = req as AuthRequest; const { date, note } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - res.json(svc.toggleCompanyHoliday(planId, date, note, req.headers['x-socket-id'] as string)); + const plan = svc.getOwnPlan(authReq.user.id); + res.json(svc.toggleCompanyHoliday(plan.id, date, note, req.headers['x-socket-id'] as string)); }); +// --------------------------------------------------------------------------- +// Stats (own plan) +// --------------------------------------------------------------------------- + router.get('/stats/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; const year = parseInt(req.params.year); - const planId = svc.getActivePlanId(authReq.user.id); - res.json({ stats: svc.getStats(planId, year) }); + res.json({ stats: svc.getAllStats(authReq.user.id, year) }); }); router.put('/stats/:year', (req: Request, res: Response) => { const authReq = req as AuthRequest; const year = parseInt(req.params.year); - const { vacation_days, target_user_id } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = svc.getPlanUsers(planId); - if (!planUsers.find(u => u.id === userId)) { - return res.status(403).json({ error: 'User not in plan' }); - } - svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string); + const { vacation_days } = req.body; + const plan = svc.getOwnPlan(authReq.user.id); + svc.updateStats(authReq.user.id, plan.id, year, vacation_days, req.headers['x-socket-id'] as string); res.json({ success: true }); }); +// --------------------------------------------------------------------------- +// Public holidays proxy (nager.at) +// --------------------------------------------------------------------------- + router.get('/holidays/countries', async (_req: Request, res: Response) => { const result = await svc.getCountries(); if (result.error) return res.status(502).json({ error: result.error }); diff --git a/server/src/services/vacayService.ts b/server/src/services/vacayService.ts index 70c6780cf..9fd3405af 100644 --- a/server/src/services/vacayService.ts +++ b/server/src/services/vacayService.ts @@ -28,14 +28,6 @@ export interface VacayUser { email: string; } -export interface VacayPlanMember { - id: number; - plan_id: number; - user_id: number; - status: string; - created_at?: string; -} - export interface Holiday { date: string; localName?: string; @@ -70,6 +62,35 @@ const COLORS = [ '#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488', ]; +function getConnectedUserIds(userId: number): number[] { + const rows = db.prepare(` + SELECT viewer_id as other_id FROM vacay_read_access WHERE granter_id = ? AND status = 'accepted' + UNION + SELECT granter_id as other_id FROM vacay_read_access WHERE viewer_id = ? AND status = 'accepted' + `).all(userId, userId) as { other_id: number }[]; + return rows.map(r => r.other_id); +} + +// Only auto-assigns if the user still has the default color — never overwrites a user's custom choice. +// avoidUserIds: whose colors to avoid (include the other party so they always get distinct colors). +function assignUniqueColorIfDefault(userId: number, planId: number, avoidUserIds: number[]): void { + const current = (db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined)?.color; + if (current && current !== COLORS[0]) return; + const usedColors = new Set(); + for (const uid of avoidUserIds) { + const p = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(uid) as { id: number } | undefined; + if (p) { + const c = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(uid, p.id) as { color: string } | undefined; + if (c) usedColors.add(c.color); + } + } + const pick = COLORS.find(c => !usedColors.has(c)) ?? COLORS[1]; + db.prepare(` + INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) + ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color + `).run(userId, planId, pick); +} + // --------------------------------------------------------------------------- // Plan management // --------------------------------------------------------------------------- @@ -87,45 +108,21 @@ export function getOwnPlan(userId: number): VacayPlan { return plan; } -export function getActivePlan(userId: number): VacayPlan { - const membership = db.prepare(` - SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted' - `).get(userId) as { plan_id: number } | undefined; - if (membership) { - return db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(membership.plan_id) as VacayPlan; - } - return getOwnPlan(userId); -} - -export function getActivePlanId(userId: number): number { - return getActivePlan(userId).id; -} - -export function getPlanUsers(planId: number): VacayUser[] { - const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - if (!plan) return []; - const owner = db.prepare('SELECT id, username, email FROM users WHERE id = ?').get(plan.owner_id) as VacayUser; - const members = db.prepare(` - SELECT u.id, u.username, u.email FROM vacay_plan_members m - JOIN users u ON m.user_id = u.id - WHERE m.plan_id = ? AND m.status = 'accepted' - `).all(planId) as VacayUser[]; - return [owner, ...members]; -} - // --------------------------------------------------------------------------- // WebSocket notifications // --------------------------------------------------------------------------- -export function notifyPlanUsers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void { +export function notifyPlanOwnerAndViewers(planId: number, excludeSid: string | undefined, event = 'vacay:update'): void { try { const { broadcastToUser } = require('../websocket'); const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined; if (!plan) return; - const userIds = [plan.owner_id]; - const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(planId) as { user_id: number }[]; - members.forEach(m => userIds.push(m.user_id)); - userIds.forEach(id => broadcastToUser(id, { type: event }, excludeSid)); + broadcastToUser(plan.owner_id, { type: event }, excludeSid); + // Also notify all viewers who have accepted access to this plan + const viewers = db.prepare(` + SELECT viewer_id FROM vacay_read_access WHERE granter_id = ? AND status = 'accepted' + `).all(plan.owner_id) as { viewer_id: number }[]; + viewers.forEach(v => broadcastToUser(v.viewer_id, { type: event }, excludeSid)); } catch { /* websocket not available */ } } @@ -223,24 +220,22 @@ export async function updatePlan(planId: number, body: UpdatePlanBody, socketId: if (carry_over_enabled === true) { const years = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year').all(planId) as { year: number }[]; - const users = getPlanUsers(planId); + const ownerId = (db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number }).owner_id; for (let i = 0; i < years.length - 1; i++) { const yr = years[i].year; const nextYr = years[i + 1].year; - for (const u of users) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${yr}-%`) as { count: number }).count; - const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, yr) as VacayUserYear | undefined; - const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); - const carry = Math.max(0, total - used); - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, nextYr, carry, carry); - } + const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(ownerId, planId, `${yr}-%`) as { count: number }).count; + const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(ownerId, planId, yr) as VacayUserYear | undefined; + const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0); + const carry = Math.max(0, total - used); + db.prepare(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `).run(ownerId, planId, nextYr, carry, carry); } } - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); const updated = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan; const updatedCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; @@ -265,7 +260,7 @@ export function addHolidayCalendar(planId: number, region: string, label: string 'INSERT INTO vacay_holiday_calendars (plan_id, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?)' ).run(planId, region, label || null, color || '#fecaca', sortOrder ?? 0); const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(result.lastInsertRowid) as VacayHolidayCalendar; - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); return cal; } @@ -289,7 +284,7 @@ export function updateHolidayCalendar( db.prepare(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`).run(...params); } const updated = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ?').get(calId) as VacayHolidayCalendar; - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); return updated; } @@ -297,12 +292,12 @@ export function deleteHolidayCalendar(calId: number, planId: number, socketId: s const cal = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?').get(calId, planId); if (!cal) return false; db.prepare('DELETE FROM vacay_holiday_calendars WHERE id = ?').run(calId); - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); return true; } // --------------------------------------------------------------------------- -// User colors +// User color // --------------------------------------------------------------------------- export function setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void { @@ -310,156 +305,129 @@ export function setUserColor(userId: number, planId: number, color: string | und INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color `).run(userId, planId, color || '#6366f1'); - notifyPlanUsers(planId, socketId, 'vacay:update'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:update'); } // --------------------------------------------------------------------------- -// Invitations +// Read-access invitations // --------------------------------------------------------------------------- -export function sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } { - if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 }; +export function grantAccess( + granterId: number, granterUsername: string, granterEmail: string, viewerUserId: number, +): { error?: string; status?: number } { + if (viewerUserId === granterId) return { error: 'Cannot share with yourself', status: 400 }; - const targetUser = db.prepare('SELECT id, username FROM users WHERE id = ?').get(targetUserId); - if (!targetUser) return { error: 'User not found', status: 404 }; + const viewer = db.prepare('SELECT id FROM users WHERE id = ?').get(viewerUserId); + if (!viewer) return { error: 'User not found', status: 404 }; - const existing = db.prepare('SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(planId, targetUserId) as { id: number; status: string } | undefined; + const existing = db.prepare('SELECT id, status FROM vacay_read_access WHERE granter_id = ? AND viewer_id = ?').get(granterId, viewerUserId) as { id: number; status: string } | undefined; if (existing) { - if (existing.status === 'accepted') return { error: 'Already fused', status: 400 }; + if (existing.status === 'accepted') return { error: 'Already connected', status: 400 }; if (existing.status === 'pending') return { error: 'Invite already pending', status: 400 }; } - const targetFusion = db.prepare("SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'").get(targetUserId); - if (targetFusion) return { error: 'User is already fused with another plan', status: 400 }; - - db.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(planId, targetUserId, 'pending'); + db.prepare('INSERT INTO vacay_read_access (granter_id, viewer_id, status) VALUES (?, ?, ?)').run(granterId, viewerUserId, 'pending'); try { const { broadcastToUser } = require('../websocket'); - broadcastToUser(targetUserId, { - type: 'vacay:invite', - from: { id: inviterId, username: inviterUsername }, - planId, + broadcastToUser(viewerUserId, { + type: 'vacay:access_invite', + from: { id: granterId, username: granterUsername }, }); } catch { /* websocket not available */ } - // Notify invited user import('../services/notificationService').then(({ send }) => { - send({ event: 'vacay_invite', actorId: inviterId, scope: 'user', targetId: targetUserId, params: { actor: inviterEmail, planId: String(planId) } }).catch(() => {}); + send({ event: 'vacay_invite', actorId: granterId, scope: 'user', targetId: viewerUserId, params: { actor: granterEmail } }).catch(() => {}); }); return {}; } -export function acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } { - const invite = db.prepare("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").get(planId, userId) as VacayPlanMember | undefined; +export function acceptAccessInvite(viewerId: number, granterId: number, socketId: string | undefined): { error?: string; status?: number } { + const invite = db.prepare("SELECT * FROM vacay_read_access WHERE granter_id = ? AND viewer_id = ? AND status = 'pending'").get(granterId, viewerId); if (!invite) return { error: 'No pending invite', status: 404 }; - db.prepare("UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?").run(invite.id); - - // Migrate data from user's own plan - const ownPlan = db.prepare('SELECT id FROM vacay_plans WHERE owner_id = ?').get(userId) as { id: number } | undefined; - if (ownPlan && ownPlan.id !== planId) { - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(planId, ownPlan.id, userId); - const ownYears = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?').all(userId, ownPlan.id) as VacayUserYear[]; - for (const y of ownYears) { - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)').run(userId, planId, y.year, y.vacation_days, y.carried_over); - } - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, ownPlan.id) as { color: string } | undefined; - if (colorRow) { - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, colorRow.color); - } - } + // Accept the existing invite (granter → viewer) + db.prepare("UPDATE vacay_read_access SET status = 'accepted' WHERE granter_id = ? AND viewer_id = ?").run(granterId, viewerId); + // Create the reverse connection (viewer → granter) for bidirectionality + db.prepare("INSERT OR IGNORE INTO vacay_read_access (granter_id, viewer_id, status) VALUES (?, ?, 'accepted')").run(viewerId, granterId); - // Auto-assign unique color - const existingColors = (db.prepare('SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?').all(planId, userId) as { color: string }[]).map(r => r.color); - const myColor = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined; - const effectiveColor = myColor?.color || '#6366f1'; - if (existingColors.includes(effectiveColor)) { - const available = COLORS.find(c => !existingColors.includes(c)); - if (available) { - db.prepare(`INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?) - ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color`).run(userId, planId, available); - } - } else if (!myColor) { - db.prepare('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)').run(userId, planId, effectiveColor); - } + // Auto-assign unique colors if still at the default — include the other party in the + // avoid-list so the two users always get distinct colors even on a first connection. + const viewerPlan = getOwnPlan(viewerId); + const granterPlan = getOwnPlan(granterId); + assignUniqueColorIfDefault(viewerId, viewerPlan.id, [...getConnectedUserIds(granterId).filter(id => id !== viewerId), granterId]); + assignUniqueColorIfDefault(granterId, granterPlan.id, [...getConnectedUserIds(viewerId).filter(id => id !== granterId), viewerId]); - // Ensure user has rows for all plan years - const targetYears = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ?').all(planId) as { year: number }[]; - for (const y of targetYears) { - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)').run(userId, planId, y.year); - } + try { + const { broadcastToUser } = require('../websocket'); + broadcastToUser(granterId, { type: 'vacay:access_accepted' }, socketId); + } catch { /* websocket not available */ } - notifyPlanUsers(planId, socketId, 'vacay:accepted'); return {}; } -export function declineInvite(userId: number, planId: number, socketId: string | undefined): void { - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, userId); - notifyPlanUsers(planId, socketId, 'vacay:declined'); -} - -export function cancelInvite(planId: number, targetUserId: number): void { - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'").run(planId, targetUserId); +export function declineAccessInvite(viewerId: number, granterId: number, socketId: string | undefined): void { + db.prepare("DELETE FROM vacay_read_access WHERE granter_id = ? AND viewer_id = ? AND status = 'pending'").run(granterId, viewerId); try { const { broadcastToUser } = require('../websocket'); - broadcastToUser(targetUserId, { type: 'vacay:cancelled' }); - } catch { /* */ } + broadcastToUser(granterId, { type: 'vacay:access_declined' }, socketId); + } catch { /* websocket not available */ } } -// --------------------------------------------------------------------------- -// Plan dissolution -// --------------------------------------------------------------------------- - -export function dissolvePlan(userId: number, socketId: string | undefined): void { - const plan = getActivePlan(userId); - const isOwnerFlag = plan.owner_id === userId; +export function cancelAccessInvite(granterId: number, viewerUserId: number): void { + db.prepare("DELETE FROM vacay_read_access WHERE granter_id = ? AND viewer_id = ? AND status = 'pending'").run(granterId, viewerUserId); - const allUserIds = getPlanUsers(plan.id).map(u => u.id); - const companyHolidays = db.prepare('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?').all(plan.id) as { date: string; note: string }[]; + try { + const { broadcastToUser } = require('../websocket'); + broadcastToUser(viewerUserId, { type: 'vacay:access_cancelled' }); + } catch { /* websocket not available */ } +} - if (isOwnerFlag) { - const members = db.prepare("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'").all(plan.id) as { user_id: number }[]; - for (const m of members) { - const memberPlan = getOwnPlan(m.user_id); - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(memberPlan.id, plan.id, m.user_id); - for (const ch of companyHolidays) { - db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(memberPlan.id, ch.date, ch.note); - } - } - db.prepare('DELETE FROM vacay_plan_members WHERE plan_id = ?').run(plan.id); - } else { - const ownPlan = getOwnPlan(userId); - db.prepare('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?').run(ownPlan.id, plan.id, userId); - for (const ch of companyHolidays) { - db.prepare('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(ownPlan.id, ch.date, ch.note); - } - db.prepare("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?").run(plan.id, userId); - } +export function revokeAccess(requesterId: number, otherUserId: number, socketId: string | undefined): void { + // Delete both directions (bidirectional connection) + db.prepare(` + DELETE FROM vacay_read_access + WHERE (granter_id = ? AND viewer_id = ?) OR (granter_id = ? AND viewer_id = ?) + `).run(requesterId, otherUserId, otherUserId, requesterId); try { const { broadcastToUser } = require('../websocket'); - allUserIds.filter(id => id !== userId).forEach(id => broadcastToUser(id, { type: 'vacay:dissolved' })); - } catch { /* */ } + broadcastToUser(otherUserId, { type: 'vacay:access_revoked' }, socketId); + broadcastToUser(requesterId, { type: 'vacay:access_revoked' }, socketId); + } catch { /* websocket not available */ } } // --------------------------------------------------------------------------- -// Available users +// Read-access user lists // --------------------------------------------------------------------------- -export function getAvailableUsers(userId: number, planId: number) { +export function getAvailableUsersForAccess(granterId: number) { return db.prepare(` SELECT u.id, u.username, u.email FROM users u WHERE u.id != ? - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE plan_id = ?) - AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE status = 'accepted') - AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id IN ( - SELECT plan_id FROM vacay_plan_members WHERE status = 'accepted' - )) + AND u.id NOT IN ( + SELECT viewer_id FROM vacay_read_access WHERE granter_id = ? + UNION + SELECT granter_id FROM vacay_read_access WHERE viewer_id = ? + ) ORDER BY u.username - `).all(userId, planId); + `).all(granterId, granterId, granterId); +} + +export function getConnectedUsers(userId: number): (VacayUser & { color: string })[] { + return db.prepare(` + SELECT DISTINCT u.id, u.username, u.email, COALESCE(c.color, '#6366f1') as color + FROM ( + SELECT viewer_id as other_id FROM vacay_read_access WHERE granter_id = ? AND status = 'accepted' + UNION + SELECT granter_id as other_id FROM vacay_read_access WHERE viewer_id = ? AND status = 'accepted' + ) connected + JOIN users u ON u.id = connected.other_id + LEFT JOIN vacay_plans p ON p.owner_id = u.id + LEFT JOIN vacay_user_colors c ON c.user_id = u.id AND c.plan_id = p.id + `).all(userId, userId) as (VacayUser & { color: string })[]; } // --------------------------------------------------------------------------- @@ -475,22 +443,20 @@ export function addYear(planId: number, year: number, socketId: string | undefin try { db.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, year); const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - for (const u of users) { - let carriedOver = 0; - if (carryOverEnabled) { - const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year - 1) as VacayUserYear | undefined; - if (prevConfig) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year - 1}-%`) as { count: number }).count; - const total = prevConfig.vacation_days + prevConfig.carried_over; - carriedOver = Math.max(0, total - used); - } + if (!plan) return listYears(planId); + const carryOverEnabled = !!plan.carry_over_enabled; + let carriedOver = 0; + if (carryOverEnabled) { + const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(plan.owner_id, planId, year - 1) as VacayUserYear | undefined; + if (prevConfig) { + const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(plan.owner_id, planId, `${year - 1}-%`) as { count: number }).count; + const total = prevConfig.vacation_days + prevConfig.carried_over; + carriedOver = Math.max(0, total - used); } - db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(u.id, planId, year, carriedOver); } + db.prepare('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?)').run(plan.owner_id, planId, year, carriedOver); } catch { /* year already exists */ } - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); return listYears(planId); } @@ -500,44 +466,45 @@ export function deleteYear(planId: number, year: number, socketId: string | unde db.prepare("DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").run(planId, `${year}-%`); db.prepare('DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?').run(planId, year); - // Recalculate carry-over for year+1 if it exists, since its previous year has changed const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1); if (nextYearExists) { const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - const prevYear = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1').get(planId, year + 1) as { year: number } | undefined; - - for (const u of users) { + if (plan) { + const carryOverEnabled = !!plan.carry_over_enabled; + const prevYear = db.prepare('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1').get(planId, year + 1) as { year: number } | undefined; let carry = 0; if (carryOverEnabled && prevYear) { - const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, prevYear.year) as VacayUserYear | undefined; + const prevConfig = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(plan.owner_id, planId, prevYear.year) as VacayUserYear | undefined; if (prevConfig) { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${prevYear.year}-%`) as { count: number }).count; + const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(plan.owner_id, planId, `${prevYear.year}-%`) as { count: number }).count; const total = prevConfig.vacation_days + prevConfig.carried_over; carry = Math.max(0, total - used); } } - db.prepare('UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?').run(carry, u.id, planId, year + 1); + db.prepare('UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?').run(carry, plan.owner_id, planId, year + 1); } } - notifyPlanUsers(planId, socketId, 'vacay:settings'); + notifyPlanOwnerAndViewers(planId, socketId, 'vacay:settings'); return listYears(planId); } // --------------------------------------------------------------------------- -// Entries +// Entries (own plan only) // --------------------------------------------------------------------------- export function getEntries(planId: number, year: string) { + const plan = db.prepare('SELECT owner_id FROM vacay_plans WHERE id = ?').get(planId) as { owner_id: number } | undefined; + if (!plan) return { entries: [], companyHolidays: [] }; + const entries = db.prepare(` SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color FROM vacay_entries e JOIN users u ON e.user_id = u.id LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id - WHERE e.plan_id = ? AND e.date LIKE ? - `).all(planId, `${year}-%`); + WHERE e.plan_id = ? AND e.user_id = ? AND e.date LIKE ? + `).all(planId, plan.owner_id, `${year}-%`); + const companyHolidays = db.prepare("SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ?").all(planId, `${year}-%`); return { entries, companyHolidays }; } @@ -546,11 +513,11 @@ export function toggleEntry(userId: number, planId: number, date: string, socket const existing = db.prepare('SELECT id FROM vacay_entries WHERE user_id = ? AND date = ? AND plan_id = ?').get(userId, date, planId) as { id: number } | undefined; if (existing) { db.prepare('DELETE FROM vacay_entries WHERE id = ?').run(existing.id); - notifyPlanUsers(planId, socketId); + notifyPlanOwnerAndViewers(planId, socketId); return { action: 'removed' }; } else { db.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, userId, date, ''); - notifyPlanUsers(planId, socketId); + notifyPlanOwnerAndViewers(planId, socketId); return { action: 'added' }; } } @@ -559,49 +526,87 @@ export function toggleCompanyHoliday(planId: number, date: string, note: string const existing = db.prepare('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?').get(planId, date) as { id: number } | undefined; if (existing) { db.prepare('DELETE FROM vacay_company_holidays WHERE id = ?').run(existing.id); - notifyPlanUsers(planId, socketId); + notifyPlanOwnerAndViewers(planId, socketId); return { action: 'removed' }; } else { db.prepare('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)').run(planId, date, note || ''); db.prepare('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?').run(planId, date); - notifyPlanUsers(planId, socketId); + notifyPlanOwnerAndViewers(planId, socketId); return { action: 'added' }; } } // --------------------------------------------------------------------------- -// Stats +// Foreign entries (from all accepted granters) +// --------------------------------------------------------------------------- + +export function getForeignEntries(userId: number, year: string) { + const granters = db.prepare(` + SELECT ra.granter_id, u.username, COALESCE(c.color, '#6366f1') as color, p.id as plan_id + FROM vacay_read_access ra + JOIN users u ON ra.granter_id = u.id + JOIN vacay_plans p ON p.owner_id = ra.granter_id + LEFT JOIN vacay_user_colors c ON c.user_id = ra.granter_id AND c.plan_id = p.id + WHERE ra.viewer_id = ? AND ra.status = 'accepted' + `).all(userId) as { granter_id: number; username: string; color: string; plan_id: number }[]; + + const entries: { date: string; user_id: number; person_name: string; person_color: string }[] = []; + + for (const g of granters) { + // Vacation entries + const vacationEntries = db.prepare(` + SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date LIKE ? + `).all(g.plan_id, g.granter_id, `${year}-%`) as { date: string }[]; + for (const e of vacationEntries) { + entries.push({ date: e.date, user_id: g.granter_id, person_name: g.username, person_color: g.color }); + } + + // Company holidays (Betriebsferien) — deduplicate per granter per date + const companyHolidays = db.prepare(` + SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date LIKE ? + `).all(g.plan_id, `${year}-%`) as { date: string }[]; + for (const ch of companyHolidays) { + if (!entries.some(e => e.date === ch.date && e.user_id === g.granter_id)) { + entries.push({ date: ch.date, user_id: g.granter_id, person_name: g.username, person_color: g.color }); + } + } + } + + return entries; +} + +// --------------------------------------------------------------------------- +// Stats (own plan only) // --------------------------------------------------------------------------- export function getStats(planId: number, year: number) { const plan = db.prepare('SELECT * FROM vacay_plans WHERE id = ?').get(planId) as VacayPlan | undefined; - const carryOverEnabled = plan ? !!plan.carry_over_enabled : true; - const users = getPlanUsers(planId); - - return users.map(u => { - const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(u.id, planId, `${year}-%`) as { count: number }).count; - const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(u.id, planId, year) as VacayUserYear | undefined; - const vacationDays = config ? config.vacation_days : 30; - const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; - const total = vacationDays + carriedOver; - const remaining = total - used; - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, planId) as { color: string } | undefined; - - const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1); - if (nextYearExists && carryOverEnabled) { - const carry = Math.max(0, remaining); - db.prepare(` - INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) - ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? - `).run(u.id, planId, year + 1, carry, carry); - } + if (!plan) return []; + const carryOverEnabled = !!plan.carry_over_enabled; - return { - user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1', - year, vacation_days: vacationDays, carried_over: carriedOver, - total_available: total, used, remaining, - }; - }); + const used = (db.prepare("SELECT COUNT(*) as count FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date LIKE ?").get(plan.owner_id, planId, `${year}-%`) as { count: number }).count; + const config = db.prepare('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?').get(plan.owner_id, planId, year) as VacayUserYear | undefined; + const vacationDays = config ? config.vacation_days : 30; + const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0; + const total = vacationDays + carriedOver; + const remaining = total - used; + const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(plan.owner_id, planId) as { color: string } | undefined; + const owner = db.prepare('SELECT username FROM users WHERE id = ?').get(plan.owner_id) as { username: string }; + + const nextYearExists = db.prepare('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?').get(planId, year + 1); + if (nextYearExists && carryOverEnabled) { + const carry = Math.max(0, remaining); + db.prepare(` + INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) + ON CONFLICT(user_id, plan_id, year) DO UPDATE SET carried_over = ? + `).run(plan.owner_id, planId, year + 1, carry, carry); + } + + return [{ + user_id: plan.owner_id, person_name: owner.username, person_color: colorRow?.color || '#6366f1', + year, vacation_days: vacationDays, carried_over: carriedOver, + total_available: total, used, remaining, + }]; } export function updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void { @@ -609,37 +614,54 @@ export function updateStats(userId: number, planId: number, year: number, vacati INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days `).run(userId, planId, year, vacationDays); - notifyPlanUsers(planId, socketId); + notifyPlanOwnerAndViewers(planId, socketId); } // --------------------------------------------------------------------------- // GET /plan composite // --------------------------------------------------------------------------- +export function getAllStats(userId: number, year: number) { + const ownPlan = getOwnPlan(userId); + const ownStats = getStats(ownPlan.id, year).map(s => ({ ...s, canEdit: true })); + + const granters = db.prepare(` + SELECT granter_id FROM vacay_read_access WHERE viewer_id = ? AND status = 'accepted' + `).all(userId) as { granter_id: number }[]; + + const allStats = [...ownStats]; + for (const { granter_id } of granters) { + const granterPlan = getOwnPlan(granter_id); + allStats.push(...getStats(granterPlan.id, year).map(s => ({ ...s, canEdit: false }))); + } + + return allStats; +} + export function getPlanData(userId: number) { - const plan = getActivePlan(userId); - const activePlanId = plan.id; + const plan = getOwnPlan(userId); + const planId = plan.id; - const users = getPlanUsers(activePlanId).map(u => { - const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(u.id, activePlanId) as { color: string } | undefined; - return { ...u, color: colorRow?.color || '#6366f1' }; - }); + const colorRow = db.prepare('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?').get(userId, planId) as { color: string } | undefined; + const myColor = colorRow?.color || '#6366f1'; - const pendingInvites = db.prepare(` - SELECT m.id, m.user_id, u.username, u.email, m.created_at - FROM vacay_plan_members m JOIN users u ON m.user_id = u.id - WHERE m.plan_id = ? AND m.status = 'pending' - `).all(activePlanId); - - const incomingInvites = db.prepare(` - SELECT m.id, m.plan_id, u.username, u.email, m.created_at - FROM vacay_plan_members m - JOIN vacay_plans p ON m.plan_id = p.id - JOIN users u ON p.owner_id = u.id - WHERE m.user_id = ? AND m.status = 'pending' + const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(planId) as VacayHolidayCalendar[]; + + const connectedUsers = getConnectedUsers(userId); + + const pendingIncoming = db.prepare(` + SELECT ra.id, ra.granter_id, u.username as granter_username + FROM vacay_read_access ra + JOIN users u ON ra.granter_id = u.id + WHERE ra.viewer_id = ? AND ra.status = 'pending' `).all(userId); - const holidayCalendars = db.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id').all(activePlanId) as VacayHolidayCalendar[]; + const pendingOutgoing = db.prepare(` + SELECT ra.id, ra.viewer_id, u.username as viewer_username + FROM vacay_read_access ra + JOIN users u ON ra.viewer_id = u.id + WHERE ra.granter_id = ? AND ra.status = 'pending' + `).all(userId); return { plan: { @@ -650,11 +672,10 @@ export function getPlanData(userId: number) { carry_over_enabled: !!plan.carry_over_enabled, holiday_calendars: holidayCalendars, }, - users, - pendingInvites, - incomingInvites, - isOwner: plan.owner_id === userId, - isFused: users.length > 1, + myColor, + connectedUsers, + pendingIncoming, + pendingOutgoing, }; }