Spotkania na serwerze: umawianie ogladania z druga strona
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Deploy Polska Lokalnie / deploy (push) Successful in 12s
Nowa tabela meetings i /api/meetings. Termin proponuje jedna ze stron rozmowy o ogloszeniu, druga potwierdza albo odrzuca; odwolac moze kazda ze stron. Kalendarz konta pokazuje oba rodzaje wpisow - umowione ogladania i wlasne notatki - i jest ten sam na kazdym urzadzeniu. Powiadomienia: wpis w watku i dzwonek przy kazdej zmianie, e-mail wylacznie przy potwierdzeniu terminu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+270
-240
@@ -271,14 +271,6 @@ const LIVE_LISTINGS_REFRESH_MS = 15000;
|
||||
|
||||
type NotificationCategory = 'listing' | 'messages' | 'system';
|
||||
|
||||
const meetingsStorageKey = 'polskalokalnie-account-meetings';
|
||||
// Identyfikatory przykladowych spotkan, ktore wczesniejsza wersja zapisywala kazdemu w localStorage.
|
||||
// Odfiltrowujemy je przy odczycie, zeby zniknely takze u osob, ktore juz je maja zapisane.
|
||||
const LEGACY_DEMO_MEETING_IDS = new Set([
|
||||
'meeting-1', 'meeting-2', 'meeting-3', 'meeting-4',
|
||||
'meeting-test-now-1', 'meeting-test-week-ago-1', 'meeting-test-prev-month-1',
|
||||
]);
|
||||
|
||||
type FavoriteTab = 'buy' | 'rent';
|
||||
|
||||
type FavoriteListing = {
|
||||
@@ -6472,24 +6464,26 @@ function AccountSidebar() {
|
||||
const messagesUnreadCount = useMessagesUnreadCount();
|
||||
// Liczba ogloszen konta pochodzi z serwera (/listings/mine) - tak samo jak lista na /konto/moje-oferty.
|
||||
const [allListingsCount, setAllListingsCount] = useState(0);
|
||||
// Spotkania zaplanowane na dzisiaj (odwolane sie nie licza).
|
||||
const todayMeetingsCount = (() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0;
|
||||
// Spotkania na dzisiaj (odwolane i odrzucone sie nie licza) - z konta, nie z przegladarki.
|
||||
const [todayMeetingsCount, setTodayMeetingsCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setTodayMeetingsCount(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(meetingsStorageKey);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
if (!Array.isArray(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
const now = new Date();
|
||||
const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
return parsed.filter((item) => item && !LEGACY_DEMO_MEETING_IDS.has(item.id) && item.dateKey === todayKey && item.status !== 'cancelled').length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
})();
|
||||
let cancelled = false;
|
||||
const now = new Date();
|
||||
const todayKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
apiFetch<ApiMeeting[]>('/meetings')
|
||||
.then((items) => {
|
||||
if (!cancelled) {
|
||||
setTodayMeetingsCount(items.filter((item) => item.date === todayKey
|
||||
&& item.status !== 'CANCELLED' && item.status !== 'DECLINED').length);
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!cancelled) { setTodayMeetingsCount(0); } });
|
||||
return () => { cancelled = true; };
|
||||
}, [user, location.pathname]);
|
||||
// Zapisane wyszukiwania sa na koncie, wiec licznik bierzemy z serwera - tak jak liczbe ofert.
|
||||
const [savedSearchCount, setSavedSearchCount] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -9095,22 +9089,54 @@ function AccountComparisonPage({ onOpenListing }: { onOpenListing: (id: number)
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMeetingsPage() {
|
||||
const { notify } = useNotifications();
|
||||
type MeetingType = 'buy' | 'rent' | 'own';
|
||||
type MeetingStatus = 'planned' | 'completed' | 'cancelled';
|
||||
type MeetingItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
location: string;
|
||||
dateKey: string;
|
||||
start: string;
|
||||
end: string;
|
||||
type: MeetingType;
|
||||
status: MeetingStatus;
|
||||
};
|
||||
// Spotkanie z API: ogladanie umowione z druga strona rozmowy albo wlasny wpis w kalendarzu.
|
||||
type ApiMeeting = {
|
||||
id: number;
|
||||
conversationId: number | null;
|
||||
listingId: number | null;
|
||||
listingTitle: string | null;
|
||||
title: string;
|
||||
note: string | null;
|
||||
location: string | null;
|
||||
date: string;
|
||||
start: string;
|
||||
end: string;
|
||||
status: 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED';
|
||||
proposedByMe: boolean;
|
||||
privateNote: boolean;
|
||||
otherName: string | null;
|
||||
cancelledByMe: boolean;
|
||||
};
|
||||
|
||||
// Serwer podaje godziny jako HH:mm:ss - w kalendarzu i polach formularza uzywamy HH:mm.
|
||||
function meetingTime(value: string): string {
|
||||
return value.slice(0, 5);
|
||||
}
|
||||
|
||||
function meetingStatusLabel(meeting: ApiMeeting): string {
|
||||
if (meeting.status === 'CONFIRMED') {
|
||||
return meeting.privateNote ? 'Wpis własny' : 'Potwierdzone';
|
||||
}
|
||||
if (meeting.status === 'DECLINED') {
|
||||
return 'Termin odrzucony';
|
||||
}
|
||||
if (meeting.status === 'CANCELLED') {
|
||||
return meeting.cancelledByMe ? 'Odwołane przez Ciebie' : 'Odwołane';
|
||||
}
|
||||
return meeting.proposedByMe ? 'Czeka na potwierdzenie' : 'Do potwierdzenia';
|
||||
}
|
||||
|
||||
function meetingStatusClass(meeting: ApiMeeting): string {
|
||||
if (meeting.status === 'CONFIRMED') {
|
||||
return 'status-completed';
|
||||
}
|
||||
if (meeting.status === 'DECLINED' || meeting.status === 'CANCELLED') {
|
||||
return 'status-cancelled';
|
||||
}
|
||||
return 'status-planned';
|
||||
}
|
||||
|
||||
function AccountMeetingsPage() {
|
||||
const formatDateKey = (value: Date) => {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
@@ -9118,15 +9144,16 @@ function AccountMeetingsPage() {
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const storageKey = 'polskalokalnie-account-meetings';
|
||||
const [calendarMonth, setCalendarMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||||
const [selectedDateKey, setSelectedDateKey] = useState(() => formatDateKey(new Date()));
|
||||
const [showBuyMeetings, setShowBuyMeetings] = useState(true);
|
||||
const [showRentMeetings, setShowRentMeetings] = useState(true);
|
||||
const [showSharedMeetings, setShowSharedMeetings] = useState(true);
|
||||
const [showOwnMeetings, setShowOwnMeetings] = useState(true);
|
||||
const [isCreateFormOpen, setIsCreateFormOpen] = useState(false);
|
||||
const [editingMeetingId, setEditingMeetingId] = useState<string | null>(null);
|
||||
const [editingMeetingId, setEditingMeetingId] = useState<number | null>(null);
|
||||
const [formError, setFormError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [meetings, setMeetings] = useState<ApiMeeting[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newMeeting, setNewMeeting] = useState({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
@@ -9134,41 +9161,23 @@ function AccountMeetingsPage() {
|
||||
dateKey: formatDateKey(new Date()),
|
||||
start: '10:00',
|
||||
end: '10:30',
|
||||
type: 'buy' as MeetingType,
|
||||
});
|
||||
const [meetings, setMeetings] = useState<MeetingItem[]>(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed
|
||||
.filter((item): item is MeetingItem => (
|
||||
item
|
||||
&& typeof item.id === 'string'
|
||||
&& !LEGACY_DEMO_MEETING_IDS.has(item.id)
|
||||
&& typeof item.title === 'string'
|
||||
&& typeof item.subtitle === 'string'
|
||||
&& typeof item.location === 'string'
|
||||
&& typeof item.dateKey === 'string'
|
||||
&& typeof item.start === 'string'
|
||||
&& typeof item.end === 'string'
|
||||
&& (item.type === 'buy' || item.type === 'rent' || item.type === 'own')
|
||||
&& (item.status === 'planned' || item.status === 'completed' || item.status === 'cancelled')
|
||||
))
|
||||
.sort((left, right) => `${left.dateKey}-${left.start}`.localeCompare(`${right.dateKey}-${right.start}`));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const reload = useCallback(() => (
|
||||
apiFetch<ApiMeeting[]>('/meetings')
|
||||
.then((items) => { setMeetings(items); return items; })
|
||||
.catch(() => { setMeetings([]); return [] as ApiMeeting[]; })
|
||||
.finally(() => setLoading(false))
|
||||
), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(meetings));
|
||||
}, [meetings]);
|
||||
let cancelled = false;
|
||||
apiFetch<ApiMeeting[]>('/meetings')
|
||||
.then((items) => { if (!cancelled) { setMeetings(items); } })
|
||||
.catch(() => { if (!cancelled) { setMeetings([]); } })
|
||||
.finally(() => { if (!cancelled) { setLoading(false); } });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const monthLabel = calendarMonth.toLocaleDateString('pl-PL', { month: 'long', year: 'numeric' });
|
||||
const todayKey = formatDateKey(new Date());
|
||||
@@ -9182,18 +9191,15 @@ function AccountMeetingsPage() {
|
||||
});
|
||||
|
||||
const visibleSelectedMeetings = meetings
|
||||
.filter((meeting) => meeting.dateKey === selectedDateKey)
|
||||
.filter((meeting) => (
|
||||
(meeting.type === 'buy' && showBuyMeetings)
|
||||
|| (meeting.type === 'rent' && showRentMeetings)
|
||||
|| (meeting.type === 'own' && showOwnMeetings)
|
||||
))
|
||||
.filter((meeting) => meeting.date === selectedDateKey)
|
||||
.filter((meeting) => (meeting.privateNote ? showOwnMeetings : showSharedMeetings))
|
||||
.sort((left, right) => left.start.localeCompare(right.start));
|
||||
|
||||
const selectedDayMeetings = meetings.filter((meeting) => meeting.date === selectedDateKey);
|
||||
const selectedDayTotals = {
|
||||
all: meetings.filter((meeting) => meeting.dateKey === selectedDateKey).length,
|
||||
planned: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'planned').length,
|
||||
completed: meetings.filter((meeting) => meeting.dateKey === selectedDateKey && meeting.status === 'completed').length,
|
||||
all: selectedDayMeetings.length,
|
||||
waiting: selectedDayMeetings.filter((meeting) => meeting.status === 'PROPOSED').length,
|
||||
confirmed: selectedDayMeetings.filter((meeting) => meeting.status === 'CONFIRMED').length,
|
||||
};
|
||||
|
||||
const selectedDateLabel = (() => {
|
||||
@@ -9206,15 +9212,7 @@ function AccountMeetingsPage() {
|
||||
};
|
||||
|
||||
const resetFormForDate = (dateKey: string) => {
|
||||
setNewMeeting({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
location: '',
|
||||
dateKey,
|
||||
start: '10:00',
|
||||
end: '10:30',
|
||||
type: 'buy',
|
||||
});
|
||||
setNewMeeting({ title: '', subtitle: '', location: '', dateKey, start: '10:00', end: '10:30' });
|
||||
setEditingMeetingId(null);
|
||||
setFormError('');
|
||||
};
|
||||
@@ -9236,65 +9234,64 @@ function AccountMeetingsPage() {
|
||||
return (hours * 60) + minutes;
|
||||
};
|
||||
|
||||
const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId?: string | null) => {
|
||||
// Odwolane i odrzucone terminy nie blokuja godziny - miejsce w kalendarzu jest znowu wolne.
|
||||
const hasTimeConflict = (candidateDateKey: string, candidateStart: string, candidateEnd: string, ignoreId: number | null) => {
|
||||
const candidateStartMinutes = toMinutes(candidateStart);
|
||||
const candidateEndMinutes = toMinutes(candidateEnd);
|
||||
return meetings.some((meeting) => {
|
||||
if (meeting.dateKey !== candidateDateKey || meeting.id === ignoreId) {
|
||||
if (meeting.date !== candidateDateKey || meeting.id === ignoreId) {
|
||||
return false;
|
||||
}
|
||||
const startMinutes = toMinutes(meeting.start);
|
||||
const endMinutes = toMinutes(meeting.end);
|
||||
return candidateStartMinutes < endMinutes && candidateEndMinutes > startMinutes;
|
||||
if (meeting.status === 'CANCELLED' || meeting.status === 'DECLINED') {
|
||||
return false;
|
||||
}
|
||||
return candidateStartMinutes < toMinutes(meetingTime(meeting.end))
|
||||
&& candidateEndMinutes > toMinutes(meetingTime(meeting.start));
|
||||
});
|
||||
};
|
||||
|
||||
const setStatus = (id: string, status: MeetingStatus) => {
|
||||
if (selectedDateIsPast) {
|
||||
return;
|
||||
}
|
||||
const target = meetings.find((meeting) => meeting.id === id);
|
||||
setMeetings((current) => current.map((meeting) => (meeting.id === id ? { ...meeting, status } : meeting)));
|
||||
if (target && status === 'cancelled') {
|
||||
notify({ category: 'system', icon: 'calendar', title: 'Spotkanie odwołane', body: `Spotkanie „${target.title}” zostało odwołane.`, link: 'meetings', dedupeKey: `meeting-${id}-cancelled` });
|
||||
} else if (target && status === 'completed') {
|
||||
notify({ category: 'system', icon: 'check', title: 'Spotkanie potwierdzone', body: `Spotkanie „${target.title}” zostało oznaczone jako zrealizowane.`, link: 'meetings', dedupeKey: `meeting-${id}-completed` });
|
||||
}
|
||||
const run = (action: Promise<unknown>, onError: string) => {
|
||||
setBusy(true);
|
||||
action
|
||||
.then(() => reload())
|
||||
.catch((error: Error) => setFormError(error.message || onError))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
const removeMeeting = (id: string) => {
|
||||
if (selectedDateIsPast) {
|
||||
return;
|
||||
}
|
||||
setMeetings((current) => current.filter((meeting) => meeting.id !== id));
|
||||
if (editingMeetingId === id) {
|
||||
const confirmMeeting = (meeting: ApiMeeting) => {
|
||||
run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/confirm`, { method: 'POST' }), 'Nie udało się potwierdzić terminu.');
|
||||
};
|
||||
|
||||
const declineMeeting = (meeting: ApiMeeting) => {
|
||||
run(apiFetch<ApiMeeting>(`/meetings/${meeting.id}/decline`, { method: 'POST' }), 'Nie udało się odrzucić terminu.');
|
||||
};
|
||||
|
||||
const cancelMeeting = (meeting: ApiMeeting) => {
|
||||
run(apiFetch<void>(`/meetings/${meeting.id}`, { method: 'DELETE' }), 'Nie udało się odwołać spotkania.');
|
||||
if (editingMeetingId === meeting.id) {
|
||||
setEditingMeetingId(null);
|
||||
setIsCreateFormOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditMeeting = (meeting: MeetingItem) => {
|
||||
if (isPastDateKey(meeting.dateKey)) {
|
||||
return;
|
||||
}
|
||||
const openEditMeeting = (meeting: ApiMeeting) => {
|
||||
setEditingMeetingId(meeting.id);
|
||||
setNewMeeting({
|
||||
title: meeting.title,
|
||||
subtitle: meeting.subtitle,
|
||||
location: meeting.location,
|
||||
dateKey: meeting.dateKey,
|
||||
start: meeting.start,
|
||||
end: meeting.end,
|
||||
type: meeting.type,
|
||||
subtitle: meeting.note ?? '',
|
||||
location: meeting.location ?? '',
|
||||
dateKey: meeting.date,
|
||||
start: meetingTime(meeting.start),
|
||||
end: meetingTime(meeting.end),
|
||||
});
|
||||
setFormError('');
|
||||
setSelectedDateKey(meeting.dateKey);
|
||||
const selectedDate = new Date(meeting.dateKey);
|
||||
setSelectedDateKey(meeting.date);
|
||||
const selectedDate = new Date(meeting.date);
|
||||
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
setIsCreateFormOpen(true);
|
||||
};
|
||||
|
||||
const addMeeting = () => {
|
||||
const saveMeeting = () => {
|
||||
if (isPastDateKey(newMeeting.dateKey)) {
|
||||
setFormError('Nie można dodawać ani edytować spotkań wstecz.');
|
||||
return;
|
||||
@@ -9312,66 +9309,31 @@ function AccountMeetingsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingMeetingId) {
|
||||
setMeetings((current) => current.map((meeting) => (meeting.id === editingMeetingId
|
||||
? {
|
||||
...meeting,
|
||||
title: newMeeting.title.trim(),
|
||||
subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane',
|
||||
location: newMeeting.location.trim() || 'Do uzupełnienia',
|
||||
dateKey: newMeeting.dateKey,
|
||||
start: newMeeting.start,
|
||||
end: newMeeting.end,
|
||||
type: newMeeting.type,
|
||||
}
|
||||
: meeting)));
|
||||
} else {
|
||||
setMeetings((current) => [
|
||||
...current,
|
||||
{
|
||||
id: `meeting-${Date.now()}`,
|
||||
title: newMeeting.title.trim(),
|
||||
subtitle: newMeeting.subtitle.trim() || 'Spotkanie ręcznie dodane',
|
||||
location: newMeeting.location.trim() || 'Do uzupełnienia',
|
||||
dateKey: newMeeting.dateKey,
|
||||
start: newMeeting.start,
|
||||
end: newMeeting.end,
|
||||
type: newMeeting.type,
|
||||
status: 'planned',
|
||||
},
|
||||
]);
|
||||
}
|
||||
const payload = {
|
||||
title: newMeeting.title.trim(),
|
||||
note: newMeeting.subtitle.trim(),
|
||||
location: newMeeting.location.trim(),
|
||||
date: newMeeting.dateKey,
|
||||
start: newMeeting.start,
|
||||
end: newMeeting.end,
|
||||
};
|
||||
const request = editingMeetingId
|
||||
? apiFetch<ApiMeeting>(`/meetings/${editingMeetingId}`, { method: 'PUT', body: JSON.stringify(payload) })
|
||||
: apiFetch<ApiMeeting>('/meetings', { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
if (!editingMeetingId) {
|
||||
notify({ category: 'system', icon: 'calendar', title: 'Spotkanie zaplanowane', body: `„${newMeeting.title.trim()}” - ${newMeeting.dateKey}, godz. ${newMeeting.start}.`, link: 'meetings' });
|
||||
}
|
||||
|
||||
setFormError('');
|
||||
setSelectedDateKey(newMeeting.dateKey);
|
||||
const selectedDate = new Date(newMeeting.dateKey);
|
||||
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
resetFormForDate(newMeeting.dateKey);
|
||||
setIsCreateFormOpen(false);
|
||||
};
|
||||
|
||||
const markerClass = (type: MeetingType) => {
|
||||
if (type === 'buy') {
|
||||
return 'buy';
|
||||
}
|
||||
if (type === 'rent') {
|
||||
return 'rent';
|
||||
}
|
||||
return 'own';
|
||||
};
|
||||
|
||||
const statusLabel = (status: MeetingStatus) => {
|
||||
if (status === 'completed') {
|
||||
return 'Zrealizowane';
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return 'Odwołane';
|
||||
}
|
||||
return 'Zaplanowane';
|
||||
setBusy(true);
|
||||
request
|
||||
.then(() => reload())
|
||||
.then(() => {
|
||||
setFormError('');
|
||||
setSelectedDateKey(newMeeting.dateKey);
|
||||
const selectedDate = new Date(newMeeting.dateKey);
|
||||
setCalendarMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
resetFormForDate(newMeeting.dateKey);
|
||||
setIsCreateFormOpen(false);
|
||||
})
|
||||
.catch((error: Error) => setFormError(error.message || 'Nie udało się zapisać spotkania.'))
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -9392,7 +9354,7 @@ function AccountMeetingsPage() {
|
||||
<div className="account-main account-settings-main meetings-main">
|
||||
<header className="settings-head meetings-head">
|
||||
<h1>Umówione spotkania</h1>
|
||||
<p>Zarządzaj swoimi spotkaniami i przeglądaj nadchodzące terminy.</p>
|
||||
<p>Oglądania umówione w rozmowach o ogłoszeniach oraz Twoje własne wpisy w kalendarzu.</p>
|
||||
</header>
|
||||
|
||||
<div className="meetings-grid">
|
||||
@@ -9408,8 +9370,7 @@ function AccountMeetingsPage() {
|
||||
className="meetings-today-button"
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
const todayKey = formatDateKey(today);
|
||||
setSelectedDateKey(todayKey);
|
||||
setSelectedDateKey(formatDateKey(today));
|
||||
setCalendarMonth(new Date(today.getFullYear(), today.getMonth(), 1));
|
||||
}}
|
||||
>
|
||||
@@ -9426,7 +9387,7 @@ function AccountMeetingsPage() {
|
||||
const isOutside = date.getMonth() !== calendarMonth.getMonth();
|
||||
const isActive = key === selectedDateKey;
|
||||
const isPast = isPastDateKey(key);
|
||||
const hasMeeting = meetings.some((meeting) => meeting.dateKey === key);
|
||||
const hasMeeting = meetings.some((meeting) => meeting.date === key && meeting.status !== 'CANCELLED');
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -9447,20 +9408,22 @@ function AccountMeetingsPage() {
|
||||
</div>
|
||||
|
||||
<button type="button" className="meetings-add-button" onClick={openCreateForm} disabled={selectedDateIsPast}>
|
||||
<Icon name="plus" /> Dodaj spotkanie
|
||||
<Icon name="plus" /> Dodaj własny wpis
|
||||
</button>
|
||||
|
||||
<section className="meetings-filters-card">
|
||||
<h3>Filtry</h3>
|
||||
<label><input type="checkbox" checked={showBuyMeetings && showRentMeetings && showOwnMeetings} onChange={(event) => {
|
||||
setShowBuyMeetings(event.target.checked);
|
||||
setShowRentMeetings(event.target.checked);
|
||||
<label><input type="checkbox" checked={showSharedMeetings && showOwnMeetings} onChange={(event) => {
|
||||
setShowSharedMeetings(event.target.checked);
|
||||
setShowOwnMeetings(event.target.checked);
|
||||
}} /> Pokaż wszystkie</label>
|
||||
<label><input type="checkbox" checked={showBuyMeetings} onChange={(event) => setShowBuyMeetings(event.target.checked)} /> Spotkania z kupującymi</label>
|
||||
<label><input type="checkbox" checked={showRentMeetings} onChange={(event) => setShowRentMeetings(event.target.checked)} /> Spotkania z wynajmującymi</label>
|
||||
<label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Spotkania własne</label>
|
||||
<label><input type="checkbox" checked={showSharedMeetings} onChange={(event) => setShowSharedMeetings(event.target.checked)} /> Oglądania z drugą stroną</label>
|
||||
<label><input type="checkbox" checked={showOwnMeetings} onChange={(event) => setShowOwnMeetings(event.target.checked)} /> Wpisy własne</label>
|
||||
</section>
|
||||
|
||||
<p className="meetings-hint">
|
||||
Termin oglądania proponujesz w rozmowie o ogłoszeniu - <Link to={ROUTES.messages}>przejdź do wiadomości</Link>.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<section className="meetings-list-card">
|
||||
@@ -9468,8 +9431,8 @@ function AccountMeetingsPage() {
|
||||
<h2>{selectedDateLabel}</h2>
|
||||
<div className="meetings-day-summary">
|
||||
<span>Wszystkie: {selectedDayTotals.all}</span>
|
||||
<span>Zaplanowane: {selectedDayTotals.planned}</span>
|
||||
<span>Zrealizowane: {selectedDayTotals.completed}</span>
|
||||
<span>Do potwierdzenia: {selectedDayTotals.waiting}</span>
|
||||
<span>Potwierdzone: {selectedDayTotals.confirmed}</span>
|
||||
</div>
|
||||
{selectedDateIsPast && (
|
||||
<p className="meetings-history-note">Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.</p>
|
||||
@@ -9477,50 +9440,54 @@ function AccountMeetingsPage() {
|
||||
</header>
|
||||
|
||||
<div className="meetings-list">
|
||||
{visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>}
|
||||
{visibleSelectedMeetings.map((meeting) => (
|
||||
<article key={meeting.id}>
|
||||
<div className="time">{meeting.start}<small>- {meeting.end}</small></div>
|
||||
<div className="info">
|
||||
<strong><i className={markerClass(meeting.type)} />{meeting.title}</strong>
|
||||
<p>{meeting.subtitle}</p>
|
||||
<small>{meeting.location}</small>
|
||||
</div>
|
||||
<div className="meeting-side-actions">
|
||||
<em className={`status-${meeting.status}`}>{statusLabel(meeting.status)}</em>
|
||||
{!selectedDateIsPast && (
|
||||
<div className="meeting-row-actions">
|
||||
{meeting.status !== 'completed' && (
|
||||
<button type="button" onClick={() => setStatus(meeting.id, 'completed')} aria-label="Oznacz jako zrealizowane"><Icon name="check" /></button>
|
||||
)}
|
||||
{meeting.status !== 'planned' && (
|
||||
<button type="button" onClick={() => setStatus(meeting.id, 'planned')} aria-label="Oznacz jako zaplanowane"><Icon name="clock" /></button>
|
||||
)}
|
||||
<button type="button" onClick={() => setStatus(meeting.id, 'cancelled')} aria-label="Oznacz jako odwołane"><Icon name="warning" /></button>
|
||||
<button type="button" onClick={() => openEditMeeting(meeting)} aria-label="Edytuj spotkanie"><Icon name="gear" /></button>
|
||||
<button type="button" onClick={() => removeMeeting(meeting.id)} aria-label="Usuń spotkanie"><Icon name="trash" /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
{loading && <p className="meetings-empty">Wczytujemy kalendarz…</p>}
|
||||
{!loading && visibleSelectedMeetings.length === 0 && <p className="meetings-empty">Brak spotkań dla wybranego dnia.</p>}
|
||||
{visibleSelectedMeetings.map((meeting) => {
|
||||
const canDecide = !meeting.privateNote && meeting.status === 'PROPOSED' && !meeting.proposedByMe;
|
||||
const canCancel = meeting.status === 'PROPOSED' || meeting.status === 'CONFIRMED';
|
||||
return (
|
||||
<article key={meeting.id}>
|
||||
<div className="time">{meetingTime(meeting.start)}<small>- {meetingTime(meeting.end)}</small></div>
|
||||
<div className="info">
|
||||
<strong><i className={meeting.privateNote ? 'own' : 'buy'} />{meeting.title}</strong>
|
||||
<p>{meeting.privateNote ? (meeting.note || 'Wpis własny') : `Z: ${meeting.otherName ?? 'druga strona'}${meeting.listingTitle ? ` · ${meeting.listingTitle}` : ''}`}</p>
|
||||
<small>{meeting.location || 'Miejsce do uzgodnienia'}</small>
|
||||
</div>
|
||||
<div className="meeting-side-actions">
|
||||
<em className={meetingStatusClass(meeting)}>{meetingStatusLabel(meeting)}</em>
|
||||
{!selectedDateIsPast && (
|
||||
<div className="meeting-row-actions">
|
||||
{canDecide && (
|
||||
<button type="button" disabled={busy} onClick={() => confirmMeeting(meeting)} aria-label="Potwierdź termin"><Icon name="check" /></button>
|
||||
)}
|
||||
{canDecide && (
|
||||
<button type="button" disabled={busy} onClick={() => declineMeeting(meeting)} aria-label="Odrzuć termin"><Icon name="warning" /></button>
|
||||
)}
|
||||
{meeting.privateNote && (
|
||||
<button type="button" disabled={busy} onClick={() => openEditMeeting(meeting)} aria-label="Edytuj wpis"><Icon name="gear" /></button>
|
||||
)}
|
||||
{meeting.conversationId != null && (
|
||||
<Link to={conversationPath(meeting.conversationId)} aria-label="Otwórz rozmowę"><Icon name="message" /></Link>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button type="button" disabled={busy} onClick={() => cancelMeeting(meeting)} aria-label={meeting.privateNote ? 'Usuń wpis' : 'Odwołaj spotkanie'}><Icon name="trash" /></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isCreateFormOpen && (
|
||||
<div className="meetings-create-form">
|
||||
<h3>{editingMeetingId ? 'Edytuj spotkanie' : 'Dodaj spotkanie ręcznie'}</h3>
|
||||
<h3>{editingMeetingId ? 'Edytuj wpis' : 'Dodaj własny wpis'}</h3>
|
||||
<div className="fields">
|
||||
<label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="np. Spotkanie z kupującym" /></label>
|
||||
<label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="np. Mieszkanie 3-pokojowe, 62 m²" /></label>
|
||||
<label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="np. ul. Mokotowska 45, Warszawa" /></label>
|
||||
<label>Nazwa spotkania<input type="text" value={newMeeting.title} onChange={(event) => setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="Np. oglądanie mieszkania" /></label>
|
||||
<label>Opis<input type="text" value={newMeeting.subtitle} onChange={(event) => setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="Np. notatka dla siebie" /></label>
|
||||
<label>Miejsce<input type="text" value={newMeeting.location} onChange={(event) => setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="Np. ul. Długa 5" /></label>
|
||||
<label>Data<input type="date" min={todayKey} value={newMeeting.dateKey} onChange={(event) => setNewMeeting((current) => ({ ...current, dateKey: event.target.value }))} /></label>
|
||||
<label>Typ
|
||||
<select value={newMeeting.type} onChange={(event) => setNewMeeting((current) => ({ ...current, type: event.target.value as MeetingType }))}>
|
||||
<option value="buy">Spotkanie z kupującym</option>
|
||||
<option value="rent">Spotkanie z wynajmującym</option>
|
||||
<option value="own">Spotkanie własne</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Godzina od<input type="time" value={newMeeting.start} onChange={(event) => setNewMeeting((current) => ({ ...current, start: event.target.value }))} /></label>
|
||||
<label>Godzina do<input type="time" value={newMeeting.end} onChange={(event) => setNewMeeting((current) => ({ ...current, end: event.target.value }))} /></label>
|
||||
</div>
|
||||
@@ -9536,10 +9503,11 @@ function AccountMeetingsPage() {
|
||||
>
|
||||
Anuluj
|
||||
</button>
|
||||
<button type="button" className="primary" onClick={addMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz spotkanie'}</button>
|
||||
<button type="button" className="primary" disabled={busy} onClick={saveMeeting}>{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz wpis'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isCreateFormOpen && formError && <p className="meetings-error">{formError}</p>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24927,6 +24895,13 @@ function MessagesPage({
|
||||
const [activeFilter, setActiveFilter] = useState<ThreadFilter>('all');
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
const [composerValue, setComposerValue] = useState('');
|
||||
// Propozycja ogladania z poziomu rozmowy - termin trafia do kalendarza obu stron.
|
||||
const meetingToday = new Date().toISOString().slice(0, 10);
|
||||
const [meetingFormOpen, setMeetingFormOpen] = useState(false);
|
||||
const [meetingForm, setMeetingForm] = useState({ date: meetingToday, start: '17:00', end: '17:30', location: '' });
|
||||
const [meetingFeedback, setMeetingFeedback] = useState('');
|
||||
const [meetingError, setMeetingError] = useState('');
|
||||
const [meetingBusy, setMeetingBusy] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [moderationFeedback, setModerationFeedback] = useState('');
|
||||
const [isBlockModalOpen, setIsBlockModalOpen] = useState(false);
|
||||
@@ -25364,6 +25339,39 @@ function MessagesPage({
|
||||
void postToActiveThread(value);
|
||||
};
|
||||
|
||||
// Propozycja terminu: zapisuje sie w kalendarzu obu stron i zostawia wpis w watku rozmowy.
|
||||
const proposeMeeting = () => {
|
||||
const conversationId = activeThread.conversationId;
|
||||
if (!conversationId || meetingBusy) {
|
||||
return;
|
||||
}
|
||||
if (meetingForm.end <= meetingForm.start) {
|
||||
setMeetingError('Godzina zakończenia musi być późniejsza niż rozpoczęcia.');
|
||||
return;
|
||||
}
|
||||
setMeetingBusy(true);
|
||||
setMeetingError('');
|
||||
apiFetch<ApiMeeting>('/meetings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
title: activeThreadListing?.title ? `Oglądanie: ${activeThreadListing.title}` : 'Oglądanie nieruchomości',
|
||||
location: meetingForm.location.trim(),
|
||||
date: meetingForm.date,
|
||||
start: meetingForm.start,
|
||||
end: meetingForm.end,
|
||||
}),
|
||||
})
|
||||
.then(() => apiFetch<ApiConversationDetail>(`/conversations/${conversationId}`))
|
||||
.then((detail) => {
|
||||
applyConversationDetail(detail);
|
||||
setMeetingFormOpen(false);
|
||||
setMeetingFeedback('Propozycja wysłana. Czekasz na potwierdzenie drugiej strony.');
|
||||
})
|
||||
.catch((error: Error) => setMeetingError(error.message || 'Nie udało się wysłać propozycji.'))
|
||||
.finally(() => setMeetingBusy(false));
|
||||
};
|
||||
|
||||
// Wizytowka to zwykla wiadomosc tekstowa ze znacznikiem - zostaje w historii rozmowy na serwerze.
|
||||
const deliverBusinessCard = (card: BusinessCard) => {
|
||||
void postToActiveThread(formatBusinessCard(card));
|
||||
@@ -25864,6 +25872,28 @@ function MessagesPage({
|
||||
}}
|
||||
disabled={!activeThreadListing}
|
||||
><span><Icon name="house" /> Zobacz ogłoszenie</span><Icon name="link" /></button>
|
||||
{/* Termin ogladania uzgadniamy w rozmowie - druga strona potwierdza go w swoim kalendarzu. */}
|
||||
{!isAdminThread && activeThread.conversationId && (
|
||||
<>
|
||||
<button type="button" onClick={() => { setMeetingFormOpen((open) => !open); setMeetingError(''); setMeetingFeedback(''); }}>
|
||||
<span><Icon name="calendar" /> Zaproponuj oglądanie</span><Icon name="chevron" />
|
||||
</button>
|
||||
{meetingFormOpen && (
|
||||
<div className="messages-meeting-form">
|
||||
<label>Data<input type="date" min={meetingToday} value={meetingForm.date} onChange={(event) => setMeetingForm((current) => ({ ...current, date: event.target.value }))} /></label>
|
||||
<div className="messages-meeting-times">
|
||||
<label>Od<input type="time" value={meetingForm.start} onChange={(event) => setMeetingForm((current) => ({ ...current, start: event.target.value }))} /></label>
|
||||
<label>Do<input type="time" value={meetingForm.end} onChange={(event) => setMeetingForm((current) => ({ ...current, end: event.target.value }))} /></label>
|
||||
</div>
|
||||
<label>Miejsce<input type="text" value={meetingForm.location} placeholder="Np. adres z ogłoszenia" onChange={(event) => setMeetingForm((current) => ({ ...current, location: event.target.value }))} /></label>
|
||||
{meetingError && <p className="messages-meeting-error">{meetingError}</p>}
|
||||
<button type="button" className="messages-meeting-send" disabled={meetingBusy} onClick={proposeMeeting}>Wyślij propozycję</button>
|
||||
<small>Druga strona potwierdzi termin albo zaproponuje inny. Wszystkie terminy zobaczysz w <Link to={ROUTES.accountMeetings}>kalendarzu spotkań</Link>.</small>
|
||||
</div>
|
||||
)}
|
||||
{meetingFeedback && <p className="messages-meeting-ok" role="status">{meetingFeedback}</p>}
|
||||
</>
|
||||
)}
|
||||
{/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */}
|
||||
{!isAdminThread && (
|
||||
<>
|
||||
|
||||
@@ -35233,3 +35233,80 @@ a.listing-detail-secondary {
|
||||
font-weight: 900;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
/* Propozycja ogladania w rozmowie i podpowiedz w kalendarzu spotkan. */
|
||||
.messages-meeting-form {
|
||||
background: #f7fafc;
|
||||
border: 1px solid #dde5ee;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.messages-meeting-form label {
|
||||
color: #4a5a6f;
|
||||
display: grid;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.messages-meeting-form input {
|
||||
border: 1px solid #d7e0eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.messages-meeting-times {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.messages-meeting-send {
|
||||
background: #10a15e;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.messages-meeting-form small {
|
||||
color: #6b7f96;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.messages-meeting-error,
|
||||
.messages-meeting-ok {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.messages-meeting-error {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.messages-meeting-ok {
|
||||
color: #0b9f5c;
|
||||
}
|
||||
|
||||
.meetings-hint {
|
||||
color: #6b7f96;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.meeting-row-actions a {
|
||||
align-items: center;
|
||||
color: #526171;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
@@ -531,3 +531,38 @@ test.describe('Zapisane wyszukiwania', () => {
|
||||
await expect(page.locator('main')).not.toContainText('Gdynia');
|
||||
});
|
||||
});
|
||||
|
||||
// Spotkania sa na koncie, a ogladanie umawiaja obie strony rozmowy o ogloszeniu.
|
||||
test.describe('Spotkania', () => {
|
||||
test('wlasny wpis w kalendarzu przezywa odswiezenie', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.accountMeetings);
|
||||
await page.getByRole('button', { name: /Dodaj własny wpis/ }).click();
|
||||
const title = `Notatka ${Date.now()}`;
|
||||
await page.locator('.meetings-create-form input[type=text]').first().fill(title);
|
||||
await page.getByRole('button', { name: 'Zapisz wpis' }).click();
|
||||
await expect(page.locator('.meetings-list')).toContainText(title);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('.meetings-list')).toContainText(title);
|
||||
|
||||
await page.locator('button[aria-label="Usuń wpis"]').first().click();
|
||||
await expect(page.locator('.meetings-list')).not.toContainText(title);
|
||||
});
|
||||
|
||||
test('w rozmowie o ogloszeniu mozna zaproponowac ogladanie', async ({ page }) => {
|
||||
await login(page);
|
||||
await goto(page, ROUTES.buy);
|
||||
await page.locator('.real-result-card').first().waitFor();
|
||||
const href = await page.locator('.real-result-card .card-stretched-link').first().getAttribute('href');
|
||||
await goto(page, href!);
|
||||
await page.getByRole('button', { name: /Napisz wiadomość/ }).first().click();
|
||||
await page.waitForFunction(() => window.location.pathname.startsWith('/wiadomosci/'));
|
||||
await page.locator('.messages-composer input[type=text]').fill('Dzien dobry, czy oferta jest aktualna?');
|
||||
await page.locator('.messages-composer .send').click();
|
||||
await page.waitForFunction(() => /wiadomosci/.test(window.location.pathname) && window.location.pathname.split('/').length === 3);
|
||||
|
||||
await page.getByRole('button', { name: /Zaproponuj oglądanie/ }).click();
|
||||
await expect(page.locator('.messages-meeting-form')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user