diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
index 5c4a945..85c3b47 100644
--- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
+++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java
@@ -106,6 +106,28 @@ public class AccountEmailService {
template("Nowa wiadomość", greeting(name), body, "Odpowiedz", link), "WIADOMOŚĆ", link);
}
+ /**
+ * Potwierdzony termin ogladania. Jedyny e-mail modulu spotkan - propozycje, odmowy
+ * i odwolania zostaja w serwisie (watek rozmowy i dzwonek).
+ */
+ public void sendMeetingConfirmed(String email, String name, String otherName, String listingTitle,
+ String when, String location, Long conversationId) {
+ String link = baseUrl + (conversationId == null ? "/konto/spotkania" : "/wiadomosci/" + conversationId);
+ String where = location == null || location.isBlank() ? "" :
+ "
Miejsce: " + HtmlUtils.htmlEscape(location) + "
";
+ String about = listingTitle == null || listingTitle.isBlank() ? "" :
+ "Ogłoszenie: " + HtmlUtils.htmlEscape(listingTitle) + "
";
+ String body = "Termin oglądania został potwierdzony przez: "
+ + HtmlUtils.htmlEscape(otherName) + " .
"
+ + ""
+ + "
Termin: " + HtmlUtils.htmlEscape(when) + "
"
+ + where + about + "
"
+ + "Jeśli termin przestanie pasować, odwołaj go w serwisie -"
+ + " druga strona od razu to zobaczy.
";
+ send(email, "Potwierdzone oglądanie: " + when,
+ template("Termin potwierdzony", greeting(name), body, "Zobacz szczegóły", link), "SPOTKANIE", link);
+ }
+
private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled()
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/Meeting.java b/backend/src/main/java/pl/polskalokalnie/meeting/Meeting.java
new file mode 100644
index 0000000..9de7734
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/Meeting.java
@@ -0,0 +1,222 @@
+package pl.polskalokalnie.meeting;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+/**
+ * Spotkanie (ogladanie nieruchomosci) albo wlasny wpis w kalendarzu konta. Wczesniej kalendarz
+ * zyl w localStorage jednej przegladarki - druga strona nic o nim nie wiedziala.
+ *
+ * Wpis prywatny ma puste conversationId i counterpartId oraz status CONFIRMED - nikt poza
+ * autorem go nie widzi. Spotkanie umowione z druga strona powstaje z rozmowy o ogloszeniu
+ * i jest widoczne dla obu uczestnikow.
+ */
+@Entity
+@Table(name = "meetings")
+public class Meeting {
+
+ public enum Status {
+ /** Termin zaproponowany, czeka na odpowiedz drugiej strony. */
+ PROPOSED,
+ /** Termin potwierdzony przez druga strone. */
+ CONFIRMED,
+ /** Druga strona odrzucila termin. */
+ DECLINED,
+ /** Odwolane - moze to zrobic kazda ze stron, do konca spotkania. */
+ CANCELLED
+ }
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "conversation_id")
+ private Long conversationId;
+
+ @Column(name = "listing_id")
+ private Long listingId;
+
+ @Column(name = "listing_title", length = 200)
+ private String listingTitle;
+
+ /** Kto zaproponowal termin (albo autor wpisu prywatnego). */
+ @Column(name = "proposer_id", nullable = false)
+ private Long proposerId;
+
+ /** Druga strona spotkania; null dla wpisu prywatnego. */
+ @Column(name = "counterpart_id")
+ private Long counterpartId;
+
+ @Column(nullable = false, length = 160)
+ private String title;
+
+ @Column(length = 200)
+ private String note;
+
+ @Column(length = 200)
+ private String location;
+
+ @Column(name = "meeting_date", nullable = false)
+ private LocalDate meetingDate;
+
+ @Column(name = "start_time", nullable = false)
+ private LocalTime startTime;
+
+ @Column(name = "end_time", nullable = false)
+ private LocalTime endTime;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private Status status = Status.PROPOSED;
+
+ /** Kto odwolal spotkanie - druga strona widzi, po czyjej stronie byla zmiana. */
+ @Column(name = "cancelled_by_id")
+ private Long cancelledById;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt = Instant.now();
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt = Instant.now();
+
+ public boolean isPrivateNote() {
+ return counterpartId == null;
+ }
+
+ public boolean isParticipant(Long userId) {
+ return proposerId.equals(userId) || (counterpartId != null && counterpartId.equals(userId));
+ }
+
+ public Long otherParticipant(Long userId) {
+ return proposerId.equals(userId) ? counterpartId : proposerId;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public Long getConversationId() {
+ return conversationId;
+ }
+
+ public void setConversationId(Long conversationId) {
+ this.conversationId = conversationId;
+ }
+
+ public Long getListingId() {
+ return listingId;
+ }
+
+ public void setListingId(Long listingId) {
+ this.listingId = listingId;
+ }
+
+ public String getListingTitle() {
+ return listingTitle;
+ }
+
+ public void setListingTitle(String listingTitle) {
+ this.listingTitle = listingTitle;
+ }
+
+ public Long getProposerId() {
+ return proposerId;
+ }
+
+ public void setProposerId(Long proposerId) {
+ this.proposerId = proposerId;
+ }
+
+ public Long getCounterpartId() {
+ return counterpartId;
+ }
+
+ public void setCounterpartId(Long counterpartId) {
+ this.counterpartId = counterpartId;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public void setTitle(String title) {
+ this.title = title;
+ }
+
+ public String getNote() {
+ return note;
+ }
+
+ public void setNote(String note) {
+ this.note = note;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ public LocalDate getMeetingDate() {
+ return meetingDate;
+ }
+
+ public void setMeetingDate(LocalDate meetingDate) {
+ this.meetingDate = meetingDate;
+ }
+
+ public LocalTime getStartTime() {
+ return startTime;
+ }
+
+ public void setStartTime(LocalTime startTime) {
+ this.startTime = startTime;
+ }
+
+ public LocalTime getEndTime() {
+ return endTime;
+ }
+
+ public void setEndTime(LocalTime endTime) {
+ this.endTime = endTime;
+ }
+
+ public Status getStatus() {
+ return status;
+ }
+
+ public void setStatus(Status status) {
+ this.status = status;
+ }
+
+ public Long getCancelledById() {
+ return cancelledById;
+ }
+
+ public void setCancelledById(Long cancelledById) {
+ this.cancelledById = cancelledById;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java
new file mode 100644
index 0000000..0b38898
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingController.java
@@ -0,0 +1,69 @@
+package pl.polskalokalnie.meeting;
+
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
+import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
+import pl.polskalokalnie.user.AppUser;
+import pl.polskalokalnie.user.UserRepository;
+
+/** Kalendarz spotkan zalogowanego uzytkownika - wlasne wpisy i ogladania umowione z druga strona. */
+@RestController
+@RequestMapping("/api/meetings")
+public class MeetingController {
+
+ private final MeetingService meetingService;
+ private final UserRepository userRepository;
+
+ public MeetingController(MeetingService meetingService, UserRepository userRepository) {
+ this.meetingService = meetingService;
+ this.userRepository = userRepository;
+ }
+
+ @GetMapping
+ public List list(Authentication authentication) {
+ return meetingService.list(me(authentication));
+ }
+
+ @PostMapping
+ public MeetingResponse create(@RequestBody MeetingRequest request, Authentication authentication) {
+ return meetingService.create(me(authentication), request);
+ }
+
+ @PutMapping("/{id}")
+ public MeetingResponse update(@PathVariable Long id, @RequestBody MeetingRequest request, Authentication authentication) {
+ return meetingService.updatePrivate(me(authentication), id, request);
+ }
+
+ @PostMapping("/{id}/confirm")
+ public MeetingResponse confirm(@PathVariable Long id, Authentication authentication) {
+ return meetingService.respond(me(authentication), id, true);
+ }
+
+ @PostMapping("/{id}/decline")
+ public MeetingResponse decline(@PathVariable Long id, Authentication authentication) {
+ return meetingService.respond(me(authentication), id, false);
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity cancel(@PathVariable Long id, Authentication authentication) {
+ meetingService.cancel(me(authentication), id);
+ return ResponseEntity.noContent().build();
+ }
+
+ private AppUser me(Authentication authentication) {
+ return userRepository.findByEmailIgnoreCase(authentication.getName())
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Zaloguj się ponownie"));
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java
new file mode 100644
index 0000000..1d20321
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingDtos.java
@@ -0,0 +1,29 @@
+package pl.polskalokalnie.meeting;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+
+public final class MeetingDtos {
+
+ private MeetingDtos() {
+ }
+
+ /**
+ * Propozycja terminu albo wlasny wpis w kalendarzu. Z conversationId powstaje spotkanie
+ * z druga strona rozmowy; bez niego - wpis prywatny, widoczny tylko dla autora.
+ */
+ public record MeetingRequest(Long conversationId, String title, String note, String location,
+ LocalDate date, LocalTime start, LocalTime end) {
+ }
+
+ /**
+ * status: PROPOSED / CONFIRMED / DECLINED / CANCELLED.
+ * proposedByMe - czy to ja zaproponowalem termin (tylko druga strona moze go potwierdzic).
+ * privateNote - wpis wlasny, bez drugiej strony.
+ */
+ public record MeetingResponse(Long id, Long conversationId, Long listingId, String listingTitle,
+ String title, String note, String location, LocalDate date, LocalTime start,
+ LocalTime end, String status, boolean proposedByMe, boolean privateNote,
+ String otherName, boolean cancelledByMe) {
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java
new file mode 100644
index 0000000..cbb9fdd
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingRepository.java
@@ -0,0 +1,13 @@
+package pl.polskalokalnie.meeting;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+public interface MeetingRepository extends JpaRepository {
+
+ @Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId"
+ + " order by m.meetingDate asc, m.startTime asc")
+ List findForUser(@Param("userId") Long userId);
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java
new file mode 100644
index 0000000..7cdbb51
--- /dev/null
+++ b/backend/src/main/java/pl/polskalokalnie/meeting/MeetingService.java
@@ -0,0 +1,270 @@
+package pl.polskalokalnie.meeting;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+import pl.polskalokalnie.auth.AccountEmailService;
+import pl.polskalokalnie.meeting.MeetingDtos.MeetingRequest;
+import pl.polskalokalnie.meeting.MeetingDtos.MeetingResponse;
+import pl.polskalokalnie.message.Conversation;
+import pl.polskalokalnie.message.ConversationRepository;
+import pl.polskalokalnie.message.ConversationService;
+import pl.polskalokalnie.notification.NotificationService;
+import pl.polskalokalnie.user.AppUser;
+import pl.polskalokalnie.user.UserRepository;
+import pl.polskalokalnie.user.UserSettings;
+import pl.polskalokalnie.user.UserSettingsRepository;
+
+/**
+ * Umawianie ogladania nieruchomosci. Termin proponuje jedna ze stron rozmowy o ogloszeniu,
+ * druga potwierdza albo odrzuca; odwolac moze kazda ze stron, do konca spotkania.
+ *
+ * Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail
+ * wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.
+ */
+@Service
+public class MeetingService {
+
+ private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy");
+ private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm");
+
+ private final MeetingRepository meetingRepository;
+ private final ConversationRepository conversationRepository;
+ private final ConversationService conversationService;
+ private final UserRepository userRepository;
+ private final UserSettingsRepository userSettingsRepository;
+ private final NotificationService notificationService;
+ private final AccountEmailService accountEmailService;
+
+ public MeetingService(MeetingRepository meetingRepository,
+ ConversationRepository conversationRepository,
+ ConversationService conversationService,
+ UserRepository userRepository,
+ UserSettingsRepository userSettingsRepository,
+ NotificationService notificationService,
+ AccountEmailService accountEmailService) {
+ this.meetingRepository = meetingRepository;
+ this.conversationRepository = conversationRepository;
+ this.conversationService = conversationService;
+ this.userRepository = userRepository;
+ this.userSettingsRepository = userSettingsRepository;
+ this.notificationService = notificationService;
+ this.accountEmailService = accountEmailService;
+ }
+
+ @Transactional(readOnly = true)
+ public List list(AppUser me) {
+ List meetings = meetingRepository.findForUser(me.getId());
+ Map others = userRepository.findAllById(meetings.stream()
+ .map(meeting -> meeting.otherParticipant(me.getId()))
+ .filter(java.util.Objects::nonNull)
+ .collect(Collectors.toSet()))
+ .stream().collect(Collectors.toMap(AppUser::getId, Function.identity()));
+ return meetings.stream().map(meeting -> toResponse(meeting, me, others.get(meeting.otherParticipant(me.getId())))).toList();
+ }
+
+ @Transactional
+ public MeetingResponse create(AppUser me, MeetingRequest request) {
+ validate(request);
+ Meeting meeting = new Meeting();
+ meeting.setProposerId(me.getId());
+ meeting.setTitle(request.title().trim());
+ meeting.setNote(trimOrNull(request.note()));
+ meeting.setLocation(trimOrNull(request.location()));
+ meeting.setMeetingDate(request.date());
+ meeting.setStartTime(request.start());
+ meeting.setEndTime(request.end());
+
+ if (request.conversationId() == null) {
+ // Wpis wlasny - nie ma z kim uzgadniac terminu, wiec od razu jest potwierdzony.
+ meeting.setStatus(Meeting.Status.CONFIRMED);
+ return toResponse(meetingRepository.save(meeting), me, null);
+ }
+
+ Conversation conversation = requireConversation(me, request.conversationId());
+ AppUser other = requireUser(conversation.otherParticipant(me.getId()));
+ meeting.setConversationId(conversation.getId());
+ meeting.setListingId(conversation.getListingId());
+ meeting.setListingTitle(conversation.getListingTitle());
+ meeting.setCounterpartId(other.getId());
+ meeting.setStatus(Meeting.Status.PROPOSED);
+ Meeting saved = meetingRepository.save(meeting);
+
+ String when = when(saved);
+ conversationService.appendMeetingNote(conversation, me,
+ "Propozycja oglądania: " + when + (saved.getLocation() == null ? "" : ", " + saved.getLocation()));
+ notify(other, "Propozycja oglądania", displayName(me) + " proponuje termin: " + when, conversation.getId());
+ return toResponse(saved, me, other);
+ }
+
+ /** Zmiana wlasnego wpisu w kalendarzu. Terminu umowionego z druga strona sie nie edytuje - odwoluje sie go i proponuje nowy. */
+ @Transactional
+ public MeetingResponse updatePrivate(AppUser me, Long meetingId, MeetingRequest request) {
+ validate(request);
+ Meeting meeting = requireParticipant(me, meetingId);
+ if (!meeting.isPrivateNote()) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT,
+ "Umówionego terminu nie da się zmienić - odwołaj go i zaproponuj nowy");
+ }
+ meeting.setTitle(request.title().trim());
+ meeting.setNote(trimOrNull(request.note()));
+ meeting.setLocation(trimOrNull(request.location()));
+ meeting.setMeetingDate(request.date());
+ meeting.setStartTime(request.start());
+ meeting.setEndTime(request.end());
+ meeting.setUpdatedAt(Instant.now());
+ return toResponse(meeting, me, null);
+ }
+
+ /** Potwierdzenie albo odrzucenie terminu - tylko przez osobe, ktora go dostala. */
+ @Transactional
+ public MeetingResponse respond(AppUser me, Long meetingId, boolean accept) {
+ Meeting meeting = requireParticipant(me, meetingId);
+ if (meeting.isPrivateNote() || meeting.getProposerId().equals(me.getId())) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Termin potwierdza osoba, która go otrzymała");
+ }
+ if (meeting.getStatus() != Meeting.Status.PROPOSED) {
+ throw new ResponseStatusException(HttpStatus.CONFLICT, "Ten termin został już rozpatrzony");
+ }
+ meeting.setStatus(accept ? Meeting.Status.CONFIRMED : Meeting.Status.DECLINED);
+ meeting.setUpdatedAt(Instant.now());
+
+ AppUser other = requireUser(meeting.getProposerId());
+ String when = when(meeting);
+ Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
+ if (conversation != null) {
+ conversationService.appendMeetingNote(conversation, me,
+ (accept ? "Potwierdzam oglądanie: " : "Nie pasuje mi termin: ") + when);
+ }
+ notify(other, accept ? "Termin potwierdzony" : "Termin odrzucony",
+ displayName(me) + (accept ? " potwierdza oglądanie: " : " nie może w terminie: ") + when,
+ meeting.getConversationId());
+ if (accept) {
+ sendConfirmationEmail(other, me, meeting);
+ sendConfirmationEmail(me, other, meeting);
+ }
+ return toResponse(meeting, me, other);
+ }
+
+ /** Odwolac moze kazda ze stron, do konca spotkania. Wpis wlasny po prostu znika. */
+ @Transactional
+ public void cancel(AppUser me, Long meetingId) {
+ Meeting meeting = requireParticipant(me, meetingId);
+ if (meeting.isPrivateNote()) {
+ meetingRepository.delete(meeting);
+ return;
+ }
+ if (meeting.getStatus() == Meeting.Status.CANCELLED) {
+ return;
+ }
+ meeting.setStatus(Meeting.Status.CANCELLED);
+ meeting.setCancelledById(me.getId());
+ meeting.setUpdatedAt(Instant.now());
+
+ AppUser other = requireUser(meeting.otherParticipant(me.getId()));
+ String when = when(meeting);
+ Conversation conversation = conversationRepository.findById(meeting.getConversationId()).orElse(null);
+ if (conversation != null) {
+ conversationService.appendMeetingNote(conversation, me, "Odwołanie oglądania: " + when);
+ }
+ notify(other, "Oglądanie odwołane", displayName(me) + " odwołuje termin: " + when, meeting.getConversationId());
+ }
+
+ private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) {
+ boolean wantsEmail = userSettingsRepository.findByUserId(recipient.getId())
+ .map(UserSettings::isNotifyMessages)
+ .orElse(true);
+ if (!wantsEmail) {
+ return;
+ }
+ accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other),
+ meeting.getListingTitle(), when(meeting), meeting.getLocation(), meeting.getConversationId());
+ }
+
+ private void notify(AppUser recipient, String title, String body, Long conversationId) {
+ notificationService.create(recipient.getEmail(), "messages", "calendar", title, body,
+ conversationId == null ? "meetings" : "messages:conversation:" + conversationId, null);
+ }
+
+ private String when(Meeting meeting) {
+ return meeting.getMeetingDate().format(DATE) + ", " + meeting.getStartTime().format(TIME)
+ + "-" + meeting.getEndTime().format(TIME);
+ }
+
+ private void validate(MeetingRequest request) {
+ if (request == null || request.title() == null || request.title().isBlank()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj nazwę spotkania");
+ }
+ if (request.date() == null || request.start() == null || request.end() == null) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj datę i godziny spotkania");
+ }
+ if (!request.end().isAfter(request.start())) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Godzina zakończenia musi być późniejsza niż rozpoczęcia");
+ }
+ if (request.date().isBefore(LocalDate.now())) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nie można umówić spotkania w przeszłości");
+ }
+ }
+
+ private Conversation requireConversation(AppUser me, Long conversationId) {
+ Conversation conversation = conversationRepository.findById(conversationId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono rozmowy"));
+ if (!conversation.isParticipant(me.getId())) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tej rozmowy");
+ }
+ return conversation;
+ }
+
+ private Meeting requireParticipant(AppUser me, Long meetingId) {
+ Meeting meeting = meetingRepository.findById(meetingId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono spotkania"));
+ if (!meeting.isParticipant(me.getId())) {
+ throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostępu do tego spotkania");
+ }
+ return meeting;
+ }
+
+ private AppUser requireUser(Long userId) {
+ return userRepository.findById(userId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto drugiej strony zostało usunięte"));
+ }
+
+ private MeetingResponse toResponse(Meeting meeting, AppUser me, AppUser other) {
+ return new MeetingResponse(
+ meeting.getId(),
+ meeting.getConversationId(),
+ meeting.getListingId(),
+ meeting.getListingTitle(),
+ meeting.getTitle(),
+ meeting.getNote(),
+ meeting.getLocation(),
+ meeting.getMeetingDate(),
+ meeting.getStartTime(),
+ meeting.getEndTime(),
+ meeting.getStatus().name(),
+ meeting.getProposerId().equals(me.getId()),
+ meeting.isPrivateNote(),
+ meeting.isPrivateNote() ? null : displayName(other),
+ meeting.getCancelledById() != null && meeting.getCancelledById().equals(me.getId()));
+ }
+
+ private static String displayName(AppUser user) {
+ if (user == null) {
+ return "Konto usunięte";
+ }
+ String name = user.getFullName();
+ return name == null || name.isBlank() ? user.getEmail() : name;
+ }
+
+ private static String trimOrNull(String value) {
+ return value == null || value.isBlank() ? null : value.trim();
+ }
+}
diff --git a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
index 3c7e637..ffe87ff 100644
--- a/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
+++ b/backend/src/main/java/pl/polskalokalnie/message/ConversationService.java
@@ -167,6 +167,24 @@ public class ConversationService {
return messageRepository.countByRecipientIdAndReadFalseAndConversationIdIsNotNull(me.getId());
}
+ /**
+ * Wpis o spotkaniu w watku rozmowy. Swiadomie NIE publikuje zdarzenia o nowej wiadomosci:
+ * o terminach powiadamia modul spotkan (dzwonek zawsze, e-mail tylko przy potwierdzeniu),
+ * wiec kazda propozycja nie ma prawa wygenerowac osobnego maila.
+ */
+ @Transactional
+ public void appendMeetingNote(Conversation conversation, AppUser sender, String content) {
+ Message message = new Message();
+ message.setConversationId(conversation.getId());
+ message.setSenderId(sender.getId());
+ message.setRecipientId(conversation.otherParticipant(sender.getId()));
+ message.setContent(content.trim());
+ messageRepository.save(message);
+
+ conversation.setLastMessageAt(Instant.now());
+ conversation.restoreForBoth();
+ }
+
private void append(Conversation conversation, AppUser sender, AppUser recipient, String content) {
textModerationService.validateOrThrow(content);
String trimmed = content.trim();
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ad46201..bc70288 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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('/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(null);
+ const [editingMeetingId, setEditingMeetingId] = useState(null);
const [formError, setFormError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [meetings, setMeetings] = useState([]);
+ 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(() => {
- 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('/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('/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, 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(`/meetings/${meeting.id}/confirm`, { method: 'POST' }), 'Nie udało się potwierdzić terminu.');
+ };
+
+ const declineMeeting = (meeting: ApiMeeting) => {
+ run(apiFetch(`/meetings/${meeting.id}/decline`, { method: 'POST' }), 'Nie udało się odrzucić terminu.');
+ };
+
+ const cancelMeeting = (meeting: ApiMeeting) => {
+ run(apiFetch(`/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(`/meetings/${editingMeetingId}`, { method: 'PUT', body: JSON.stringify(payload) })
+ : apiFetch('/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() {
Umówione spotkania
- Zarządzaj swoimi spotkaniami i przeglądaj nadchodzące terminy.
+ Oglądania umówione w rozmowach o ogłoszeniach oraz Twoje własne wpisy w kalendarzu.
@@ -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 (
- Dodaj spotkanie
+ Dodaj własny wpis
+
+
+ Termin oglądania proponujesz w rozmowie o ogłoszeniu - przejdź do wiadomości.
+
@@ -9468,8 +9431,8 @@ function AccountMeetingsPage() {
{selectedDateLabel}
Wszystkie: {selectedDayTotals.all}
- Zaplanowane: {selectedDayTotals.planned}
- Zrealizowane: {selectedDayTotals.completed}
+ Do potwierdzenia: {selectedDayTotals.waiting}
+ Potwierdzone: {selectedDayTotals.confirmed}
{selectedDateIsPast && (
Dzień archiwalny: możesz wyłącznie przeglądać historię spotkań.
@@ -9477,50 +9440,54 @@ function AccountMeetingsPage() {
- {visibleSelectedMeetings.length === 0 &&
Brak spotkań dla wybranego dnia.
}
- {visibleSelectedMeetings.map((meeting) => (
-
- {meeting.start}- {meeting.end}
-
-
{meeting.title}
-
{meeting.subtitle}
-
{meeting.location}
-
-
-
{statusLabel(meeting.status)}
- {!selectedDateIsPast && (
-
- {meeting.status !== 'completed' && (
- setStatus(meeting.id, 'completed')} aria-label="Oznacz jako zrealizowane">
- )}
- {meeting.status !== 'planned' && (
- setStatus(meeting.id, 'planned')} aria-label="Oznacz jako zaplanowane">
- )}
- setStatus(meeting.id, 'cancelled')} aria-label="Oznacz jako odwołane">
- openEditMeeting(meeting)} aria-label="Edytuj spotkanie">
- removeMeeting(meeting.id)} aria-label="Usuń spotkanie">
-
- )}
-
-
- ))}
+ {loading &&
Wczytujemy kalendarz…
}
+ {!loading && visibleSelectedMeetings.length === 0 &&
Brak spotkań dla wybranego dnia.
}
+ {visibleSelectedMeetings.map((meeting) => {
+ const canDecide = !meeting.privateNote && meeting.status === 'PROPOSED' && !meeting.proposedByMe;
+ const canCancel = meeting.status === 'PROPOSED' || meeting.status === 'CONFIRMED';
+ return (
+
+ {meetingTime(meeting.start)}- {meetingTime(meeting.end)}
+
+
{meeting.title}
+
{meeting.privateNote ? (meeting.note || 'Wpis własny') : `Z: ${meeting.otherName ?? 'druga strona'}${meeting.listingTitle ? ` · ${meeting.listingTitle}` : ''}`}
+
{meeting.location || 'Miejsce do uzgodnienia'}
+
+
+
{meetingStatusLabel(meeting)}
+ {!selectedDateIsPast && (
+
+ {canDecide && (
+ confirmMeeting(meeting)} aria-label="Potwierdź termin">
+ )}
+ {canDecide && (
+ declineMeeting(meeting)} aria-label="Odrzuć termin">
+ )}
+ {meeting.privateNote && (
+ openEditMeeting(meeting)} aria-label="Edytuj wpis">
+ )}
+ {meeting.conversationId != null && (
+
+ )}
+ {canCancel && (
+ cancelMeeting(meeting)} aria-label={meeting.privateNote ? 'Usuń wpis' : 'Odwołaj spotkanie'}>
+ )}
+
+ )}
+
+
+ );
+ })}
{isCreateFormOpen && (
-
{editingMeetingId ? 'Edytuj spotkanie' : 'Dodaj spotkanie ręcznie'}
+
{editingMeetingId ? 'Edytuj wpis' : 'Dodaj własny wpis'}
- Nazwa spotkania setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="np. Spotkanie z kupującym" />
- Opis setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="np. Mieszkanie 3-pokojowe, 62 m²" />
- Miejsce setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="np. ul. Mokotowska 45, Warszawa" />
+ Nazwa spotkania setNewMeeting((current) => ({ ...current, title: event.target.value }))} placeholder="Np. oglądanie mieszkania" />
+ Opis setNewMeeting((current) => ({ ...current, subtitle: event.target.value }))} placeholder="Np. notatka dla siebie" />
+ Miejsce setNewMeeting((current) => ({ ...current, location: event.target.value }))} placeholder="Np. ul. Długa 5" />
Data setNewMeeting((current) => ({ ...current, dateKey: event.target.value }))} />
- Typ
- setNewMeeting((current) => ({ ...current, type: event.target.value as MeetingType }))}>
- Spotkanie z kupującym
- Spotkanie z wynajmującym
- Spotkanie własne
-
-
Godzina od setNewMeeting((current) => ({ ...current, start: event.target.value }))} />
Godzina do setNewMeeting((current) => ({ ...current, end: event.target.value }))} />
@@ -9536,10 +9503,11 @@ function AccountMeetingsPage() {
>
Anuluj
-
{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz spotkanie'}
+
{editingMeetingId ? 'Zapisz zmiany' : 'Zapisz wpis'}
)}
+ {!isCreateFormOpen && formError &&
{formError}
}
@@ -24927,6 +24895,13 @@ function MessagesPage({
const [activeFilter, setActiveFilter] = useState('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('/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(`/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}
> Zobacz ogłoszenie
+ {/* Termin ogladania uzgadniamy w rozmowie - druga strona potwierdza go w swoim kalendarzu. */}
+ {!isAdminThread && activeThread.conversationId && (
+ <>
+ { setMeetingFormOpen((open) => !open); setMeetingError(''); setMeetingFeedback(''); }}>
+ Zaproponuj oglądanie
+
+ {meetingFormOpen && (
+
+ )}
+ {meetingFeedback && {meetingFeedback}
}
+ >
+ )}
{/* Rozmowy z obsluga serwisu nie blokujemy, nie zglaszamy ani nie usuwamy. */}
{!isAdminThread && (
<>
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 0ea7cdc..2d933cd 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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;
+}
diff --git a/frontend/tests/routing.spec.ts b/frontend/tests/routing.spec.ts
index e6fac2a..69acdb9 100644
--- a/frontend/tests/routing.spec.ts
+++ b/frontend/tests/routing.spec.ts
@@ -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();
+ });
+});
diff --git a/scripts/cleanup-test-data.sql b/scripts/cleanup-test-data.sql
index 79c850a..8ceef29 100644
--- a/scripts/cleanup-test-data.sql
+++ b/scripts/cleanup-test-data.sql
@@ -109,6 +109,10 @@ DELETE FROM auth_tokens WHERE lower(user_email) NOT IN (SELECT email FROM k
DELETE FROM phone_otps WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM support_messages WHERE lower(account_email) NOT IN (SELECT email FROM keep_emails);
DELETE FROM saved_searches WHERE lower(user_email) NOT IN (SELECT email FROM keep_emails);
+-- Spotkanie z usunietym kontem znika w calosci, takze po stronie konta, ktore zostaje.
+DELETE FROM meetings
+ WHERE proposer_id NOT IN (SELECT id FROM keep_users)
+ OR (counterpart_id IS NOT NULL AND counterpart_id NOT IN (SELECT id FROM keep_users));
DELETE FROM user_settings WHERE user_id NOT IN (SELECT id FROM keep_users);
DELETE FROM app_users WHERE id IN (SELECT id FROM del_users);