feat: add recurring event deletion with three modes
Implement three deletion modes for recurring events: - single: exclude specific occurrence via EXDATE mechanism - future: set RRULE UNTIL to stop future occurrences - all: delete entire event series Changes include: - Add exceptionDates field to CalendarEvent model - Add RecurringDeleteMode type and DeleteRecurringEventDTO - EventService.deleteRecurring() with mode-based logic using rrule library - EventController DELETE endpoint accepts mode/occurrenceDate query params - recurrenceExpander filters out exception dates during expansion - AI tools support deleteMode and occurrenceDate for proposed deletions - ChatService.confirmEvent() handles recurring delete modes - New DeleteEventModal component for unified delete confirmation UI - Calendar screen integrates modal for both recurring and non-recurring events
This commit is contained in:
@@ -15,9 +15,7 @@ const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg p-4 mb-4 border-4"
|
||||
style={{
|
||||
backgroundColor: isLoading
|
||||
? theme.disabledButton
|
||||
: theme.chatBot,
|
||||
backgroundColor: isLoading ? theme.disabledButton : theme.chatBot,
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
|
||||
@@ -8,7 +8,7 @@ type BaseButtonProps = {
|
||||
solid?: boolean;
|
||||
};
|
||||
|
||||
const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
|
||||
const BaseButton = ({ children, onPress, solid = false }: BaseButtonProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<Pressable
|
||||
@@ -16,9 +16,7 @@ const BaseButton = ({children, onPress, solid = false}: BaseButtonProps) => {
|
||||
onPress={onPress}
|
||||
style={{
|
||||
borderColor: theme.borderPrimary,
|
||||
backgroundColor: solid
|
||||
? theme.chatBot
|
||||
: theme.primeBg,
|
||||
backgroundColor: solid ? theme.chatBot : theme.primeBg,
|
||||
shadowColor: theme.shadowColor,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
|
||||
@@ -10,7 +10,12 @@ type ChatBubbleProps = {
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
export function ChatBubble({ side, children, className = "", style }: ChatBubbleProps) {
|
||||
export function ChatBubble({
|
||||
side,
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
}: ChatBubbleProps) {
|
||||
const { theme } = useThemeStore();
|
||||
const borderColor = side === "left" ? theme.chatBot : theme.primeFg;
|
||||
const sideClass =
|
||||
@@ -21,7 +26,10 @@ export function ChatBubble({ side, children, className = "", style }: ChatBubble
|
||||
return (
|
||||
<View
|
||||
className={`border-2 border-solid rounded-xl my-2 ${sideClass} ${className}`}
|
||||
style={[{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg }, style]}
|
||||
style={[
|
||||
{ borderColor, elevation: 8, backgroundColor: theme.secondaryBg },
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
162
apps/client/src/components/DeleteEventModal.tsx
Normal file
162
apps/client/src/components/DeleteEventModal.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Modal, Pressable, Text, View } from "react-native";
|
||||
import { RecurringDeleteMode } from "@calchat/shared";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
|
||||
type DeleteEventModalProps = {
|
||||
visible: boolean;
|
||||
eventTitle: string;
|
||||
isRecurring: boolean;
|
||||
onConfirm: (mode: RecurringDeleteMode) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
type DeleteOption = {
|
||||
mode: RecurringDeleteMode;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const RECURRING_DELETE_OPTIONS: DeleteOption[] = [
|
||||
{
|
||||
mode: "single",
|
||||
label: "Nur dieses Vorkommen",
|
||||
description: "Nur der ausgewaehlte Termin wird entfernt",
|
||||
},
|
||||
{
|
||||
mode: "future",
|
||||
label: "Dieses und zukuenftige",
|
||||
description: "Dieser und alle folgenden Termine werden entfernt",
|
||||
},
|
||||
{
|
||||
mode: "all",
|
||||
label: "Alle Vorkommen",
|
||||
description: "Die gesamte Terminserie wird geloescht",
|
||||
},
|
||||
];
|
||||
|
||||
export const DeleteEventModal = ({
|
||||
visible,
|
||||
eventTitle,
|
||||
isRecurring,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: DeleteEventModalProps) => {
|
||||
const { theme } = useThemeStore();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={true}
|
||||
animationType="fade"
|
||||
onRequestClose={onCancel}
|
||||
>
|
||||
<Pressable
|
||||
className="flex-1 justify-center items-center"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Pressable
|
||||
className="w-11/12 rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.primeBg,
|
||||
borderWidth: 4,
|
||||
borderColor: theme.borderPrimary,
|
||||
}}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<View
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
backgroundColor: theme.chatBot,
|
||||
borderBottomWidth: 3,
|
||||
borderBottomColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-bold text-lg"
|
||||
style={{ color: theme.textPrimary }}
|
||||
>
|
||||
{isRecurring
|
||||
? "Wiederkehrenden Termin loeschen"
|
||||
: "Termin loeschen"}
|
||||
</Text>
|
||||
<Text style={{ color: theme.textSecondary }} numberOfLines={1}>
|
||||
{eventTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="p-4">
|
||||
{isRecurring ? (
|
||||
// Recurring event: show three options
|
||||
RECURRING_DELETE_OPTIONS.map((option) => (
|
||||
<Pressable
|
||||
key={option.mode}
|
||||
onPress={() => onConfirm(option.mode)}
|
||||
className="py-3 px-4 rounded-lg mb-2"
|
||||
style={{
|
||||
backgroundColor: theme.secondaryBg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-medium text-base"
|
||||
style={{ color: theme.textPrimary }}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-sm mt-1"
|
||||
style={{ color: theme.textMuted }}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))
|
||||
) : (
|
||||
// Non-recurring event: simple confirmation
|
||||
<View>
|
||||
<Text
|
||||
className="text-base mb-4"
|
||||
style={{ color: theme.textPrimary }}
|
||||
>
|
||||
Möchtest du diesen Termin wirklich löschen?
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => onConfirm("all")}
|
||||
className="py-3 px-4 rounded-lg"
|
||||
style={{
|
||||
backgroundColor: theme.rejectButton,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className="font-medium text-base text-center"
|
||||
style={{ color: theme.buttonText }}
|
||||
>
|
||||
Loeschen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Cancel button */}
|
||||
<Pressable
|
||||
onPress={onCancel}
|
||||
className="py-3 items-center"
|
||||
style={{
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.placeholderBg,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: theme.primeFg }} className="font-bold">
|
||||
Abbrechen
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -41,11 +41,7 @@ export const EventCard = ({ event, onEdit, onDelete }: EventCardProps) => {
|
||||
borderColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Feather
|
||||
name="trash-2"
|
||||
size={18}
|
||||
color={theme.textPrimary}
|
||||
/>
|
||||
<Feather name="trash-2" size={18} color={theme.textPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</EventCardBase>
|
||||
|
||||
@@ -75,11 +75,19 @@ export const EventCardBase = ({
|
||||
borderBottomColor: theme.borderPrimary,
|
||||
}}
|
||||
>
|
||||
<Text className="font-bold text-base" style={{ color: theme.textPrimary }}>{title}</Text>
|
||||
<Text
|
||||
className="font-bold text-base"
|
||||
style={{ color: theme.textPrimary }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="px-3 py-2" style={{ backgroundColor: theme.secondaryBg }}>
|
||||
<View
|
||||
className="px-3 py-2"
|
||||
style={{ backgroundColor: theme.secondaryBg }}
|
||||
>
|
||||
{/* Date */}
|
||||
<View className="flex-row items-center mb-1">
|
||||
<Feather
|
||||
@@ -116,18 +124,13 @@ export const EventCardBase = ({
|
||||
color={theme.textPrimary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
Wiederkehrend
|
||||
</Text>
|
||||
<Text style={{ color: theme.textPrimary }}>Wiederkehrend</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<Text
|
||||
style={{ color: theme.textPrimary }}
|
||||
className="text-sm mt-1"
|
||||
>
|
||||
<Text style={{ color: theme.textPrimary }} className="text-sm mt-1">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,9 @@ const EventConfirmDialog = ({
|
||||
<Modal visible={false} transparent animationType="fade">
|
||||
<View>
|
||||
<Pressable>
|
||||
<Text style={{ color: theme.textPrimary }}>EventConfirmDialog - Not Implemented</Text>
|
||||
<Text style={{ color: theme.textPrimary }}>
|
||||
EventConfirmDialog - Not Implemented
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { ProposedEventChange } from "@calchat/shared";
|
||||
import { ProposedEventChange, RecurringDeleteMode } from "@calchat/shared";
|
||||
import { useThemeStore } from "../stores/ThemeStore";
|
||||
import { EventCardBase } from "./EventCardBase";
|
||||
|
||||
const DELETE_MODE_LABELS: Record<RecurringDeleteMode, string> = {
|
||||
single: "Nur dieses Vorkommen",
|
||||
future: "Dieses & zukuenftige",
|
||||
all: "Alle Vorkommen",
|
||||
};
|
||||
|
||||
type ProposedEventCardProps = {
|
||||
proposedChange: ProposedEventChange;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
};
|
||||
|
||||
const DeleteModeBadge = ({ mode }: { mode: RecurringDeleteMode }) => {
|
||||
const { theme } = useThemeStore();
|
||||
return (
|
||||
<View
|
||||
className="self-start px-2 py-1 rounded-md mb-2"
|
||||
style={{ backgroundColor: theme.rejectButton }}
|
||||
>
|
||||
<Text style={{ color: theme.buttonText }} className="text-xs font-medium">
|
||||
{DELETE_MODE_LABELS[mode]}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmRejectButtons = ({
|
||||
isDisabled,
|
||||
respondedAction,
|
||||
@@ -68,6 +88,12 @@ export const ProposedEventCard = ({
|
||||
// respondedAction is now part of the proposedChange
|
||||
const isDisabled = !!proposedChange.respondedAction;
|
||||
|
||||
// Show delete mode badge for delete actions on recurring events
|
||||
const showDeleteModeBadge =
|
||||
proposedChange.action === "delete" &&
|
||||
event?.isRecurring &&
|
||||
proposedChange.deleteMode;
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
@@ -82,6 +108,9 @@ export const ProposedEventCard = ({
|
||||
description={event.description}
|
||||
isRecurring={event.isRecurring}
|
||||
>
|
||||
{showDeleteModeBadge && (
|
||||
<DeleteModeBadge mode={proposedChange.deleteMode!} />
|
||||
)}
|
||||
<ConfirmRejectButtons
|
||||
isDisabled={isDisabled}
|
||||
respondedAction={proposedChange.respondedAction}
|
||||
|
||||
Reference in New Issue
Block a user