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:
@@ -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() ? "" :
|
||||
"<p style=\"margin:0 0 4px\"><strong>Miejsce:</strong> " + HtmlUtils.htmlEscape(location) + "</p>";
|
||||
String about = listingTitle == null || listingTitle.isBlank() ? "" :
|
||||
"<p style=\"margin:0 0 4px\"><strong>Ogłoszenie:</strong> " + HtmlUtils.htmlEscape(listingTitle) + "</p>";
|
||||
String body = "<p>Termin oglądania został potwierdzony przez: <strong>"
|
||||
+ HtmlUtils.htmlEscape(otherName) + "</strong>.</p>"
|
||||
+ "<div style=\"margin:0 0 12px;padding:12px 14px;background:#f1f5f9;border-left:4px solid #12a764;border-radius:6px;\">"
|
||||
+ "<p style=\"margin:0 0 4px\"><strong>Termin:</strong> " + HtmlUtils.htmlEscape(when) + "</p>"
|
||||
+ where + about + "</div>"
|
||||
+ "<p style=\"color:#6b7f96;font-size:13px\">Jeśli termin przestanie pasować, odwołaj go w serwisie -"
|
||||
+ " druga strona od razu to zobaczy.</p>";
|
||||
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()
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -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<MeetingResponse> 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<Void> 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"));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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<Meeting, Long> {
|
||||
|
||||
@Query("select m from Meeting m where m.proposerId = :userId or m.counterpartId = :userId"
|
||||
+ " order by m.meetingDate asc, m.startTime asc")
|
||||
List<Meeting> findForUser(@Param("userId") Long userId);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>Powiadomienia: wpis w watku rozmowy i pod dzwonkiem przy kazdej zmianie, a e-mail
|
||||
* wylacznie przy potwierdzeniu terminu - swiadomie nie zasypujemy skrzynek.</p>
|
||||
*/
|
||||
@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<MeetingResponse> list(AppUser me) {
|
||||
List<Meeting> meetings = meetingRepository.findForUser(me.getId());
|
||||
Map<Long, AppUser> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user