Users can now create sync rules to automatically sync events between any two connected calendar providers (Google, Apple, Outlook, Synology). - New Prisma models: CalendarSyncRule + CalendarSyncMapping - Sync engine in src/lib/calendar-cross-sync.ts: fingerprint-based change detection, anti-loop protection, one-way and two-way support - API routes: GET/POST rules, PATCH/DELETE rule by id, POST run - CalendarSyncRulesPanel component with rule list, add/edit form, toggle enable/disable, Sync Now button with result summary - New "Sync" tab in settings sidebar (between Connections and Account) - Sync rules run automatically after each forced calendar cache refresh v1.76.0
537 lines
21 KiB
TypeScript
537 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import { Plus, Trash2, RefreshCcw, Check, AlertCircle, ArrowRight, ArrowLeftRight, Pencil, X } from "lucide-react";
|
||
|
||
interface CalendarSyncRule {
|
||
id: string;
|
||
name: string;
|
||
enabled: boolean;
|
||
direction: "one-way" | "two-way";
|
||
sourceConnectionId: string;
|
||
sourceCalendarId: string;
|
||
targetConnectionId: string;
|
||
targetCalendarId: string;
|
||
syncDescription: boolean;
|
||
syncLocation: boolean;
|
||
syncRecurring: boolean;
|
||
titlePrefix: string;
|
||
lastSyncedAt?: string | null;
|
||
}
|
||
|
||
interface CalendarInfo {
|
||
id: string;
|
||
title: string;
|
||
color?: string;
|
||
editable?: boolean;
|
||
}
|
||
|
||
interface ConnectionInfo {
|
||
id: string;
|
||
provider: string;
|
||
calendars?: CalendarInfo[];
|
||
}
|
||
|
||
interface Props {
|
||
connections: ConnectionInfo[];
|
||
darkMode?: boolean;
|
||
t: Record<string, string>;
|
||
}
|
||
|
||
const PROVIDER_LABELS: Record<string, string> = {
|
||
google: "Google",
|
||
apple: "Apple",
|
||
outlook: "Outlook",
|
||
synology: "Synology",
|
||
};
|
||
|
||
function calendarLabel(conn: ConnectionInfo, calId: string): string {
|
||
const cal = conn.calendars?.find((c) => c.id === calId);
|
||
return cal?.title || calId;
|
||
}
|
||
|
||
function providerLabel(conn: ConnectionInfo): string {
|
||
return PROVIDER_LABELS[conn.provider] || conn.provider;
|
||
}
|
||
|
||
// Flatten all (connectionId, calendarId) pairs for picker usage
|
||
function allCalendarOptions(connections: ConnectionInfo[]) {
|
||
const opts: { connId: string; calId: string; label: string; connLabel: string; color?: string }[] = [];
|
||
for (const conn of connections) {
|
||
if (!conn.calendars) continue;
|
||
for (const cal of conn.calendars) {
|
||
opts.push({
|
||
connId: conn.id,
|
||
calId: cal.id,
|
||
label: cal.title,
|
||
connLabel: providerLabel(conn),
|
||
color: cal.color,
|
||
});
|
||
}
|
||
}
|
||
return opts;
|
||
}
|
||
|
||
const EMPTY_FORM = {
|
||
name: "",
|
||
enabled: true,
|
||
direction: "one-way" as "one-way" | "two-way",
|
||
sourceConnectionId: "",
|
||
sourceCalendarId: "",
|
||
targetConnectionId: "",
|
||
targetCalendarId: "",
|
||
syncDescription: true,
|
||
syncLocation: true,
|
||
syncRecurring: false,
|
||
titlePrefix: "",
|
||
};
|
||
|
||
export default function CalendarSyncRulesPanel({ connections, darkMode, t }: Props) {
|
||
const [rules, setRules] = useState<CalendarSyncRule[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [syncResults, setSyncResults] = useState<{ ruleId: string; ruleName: string; created: number; updated: number; deleted: number; error?: string }[] | null>(null);
|
||
const [showForm, setShowForm] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [form, setForm] = useState({ ...EMPTY_FORM });
|
||
const [formError, setFormError] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [msg, setMsg] = useState<{ text: string; type: "success" | "error" } | null>(null);
|
||
|
||
const calOptions = allCalendarOptions(connections);
|
||
|
||
const fetchRules = useCallback(async () => {
|
||
try {
|
||
const res = await fetch("/api/calendar/cross-sync/rules");
|
||
const data = await res.json();
|
||
setRules(data.rules || []);
|
||
} catch {
|
||
// silently fail
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { fetchRules(); }, [fetchRules]);
|
||
|
||
const openNewForm = () => {
|
||
setForm({ ...EMPTY_FORM });
|
||
setEditingId(null);
|
||
setFormError("");
|
||
setShowForm(true);
|
||
};
|
||
|
||
const openEditForm = (rule: CalendarSyncRule) => {
|
||
setForm({
|
||
name: rule.name,
|
||
enabled: rule.enabled,
|
||
direction: rule.direction,
|
||
sourceConnectionId: rule.sourceConnectionId,
|
||
sourceCalendarId: rule.sourceCalendarId,
|
||
targetConnectionId: rule.targetConnectionId,
|
||
targetCalendarId: rule.targetCalendarId,
|
||
syncDescription: rule.syncDescription,
|
||
syncLocation: rule.syncLocation,
|
||
syncRecurring: rule.syncRecurring,
|
||
titlePrefix: rule.titlePrefix,
|
||
});
|
||
setEditingId(rule.id);
|
||
setFormError("");
|
||
setShowForm(true);
|
||
};
|
||
|
||
const closeForm = () => { setShowForm(false); setEditingId(null); };
|
||
|
||
const saveForm = async () => {
|
||
if (!form.sourceCalendarId || !form.targetCalendarId) {
|
||
setFormError("Please select a source and target calendar.");
|
||
return;
|
||
}
|
||
if (form.sourceConnectionId === form.targetConnectionId && form.sourceCalendarId === form.targetCalendarId) {
|
||
setFormError("Source and target calendar must be different.");
|
||
return;
|
||
}
|
||
setSaving(true);
|
||
setFormError("");
|
||
try {
|
||
const url = editingId
|
||
? `/api/calendar/cross-sync/rules/${editingId}`
|
||
: "/api/calendar/cross-sync/rules";
|
||
const method = editingId ? "PATCH" : "POST";
|
||
const res = await fetch(url, {
|
||
method,
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(form),
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || "Failed to save");
|
||
}
|
||
await fetchRules();
|
||
closeForm();
|
||
setMsg({ text: editingId ? "Rule updated." : "Rule created.", type: "success" });
|
||
setTimeout(() => setMsg(null), 3000);
|
||
} catch (err: any) {
|
||
setFormError(err.message || "Failed to save rule");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const deleteRule = async (ruleId: string) => {
|
||
if (!confirm("Delete this sync rule? Existing synced events will NOT be removed.")) return;
|
||
try {
|
||
await fetch(`/api/calendar/cross-sync/rules/${ruleId}`, { method: "DELETE" });
|
||
setRules((prev) => prev.filter((r) => r.id !== ruleId));
|
||
setMsg({ text: "Rule deleted.", type: "success" });
|
||
setTimeout(() => setMsg(null), 3000);
|
||
} catch {
|
||
setMsg({ text: "Failed to delete rule.", type: "error" });
|
||
setTimeout(() => setMsg(null), 4000);
|
||
}
|
||
};
|
||
|
||
const toggleEnabled = async (rule: CalendarSyncRule) => {
|
||
const newVal = !rule.enabled;
|
||
setRules((prev) => prev.map((r) => r.id === rule.id ? { ...r, enabled: newVal } : r));
|
||
await fetch(`/api/calendar/cross-sync/rules/${rule.id}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ enabled: newVal }),
|
||
});
|
||
};
|
||
|
||
const runSync = async () => {
|
||
setSyncing(true);
|
||
setSyncResults(null);
|
||
try {
|
||
const res = await fetch("/api/calendar/cross-sync/run", { method: "POST" });
|
||
const data = await res.json();
|
||
setSyncResults(data.results || []);
|
||
} catch {
|
||
setMsg({ text: "Sync failed.", type: "error" });
|
||
setTimeout(() => setMsg(null), 4000);
|
||
} finally {
|
||
setSyncing(false);
|
||
}
|
||
};
|
||
|
||
// Derive connection for a given connectionId
|
||
const connFor = (id: string) => connections.find((c) => c.id === id);
|
||
|
||
const label = (s: string) => (
|
||
<label style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--weekly-settings-label)", display: "block", marginBottom: "4px" }}>
|
||
{s}
|
||
</label>
|
||
);
|
||
|
||
const inputStyle: React.CSSProperties = {
|
||
width: "100%", padding: "7px 10px", borderRadius: "7px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
background: "var(--weekly-input-bg, var(--weekly-bg, #fff))",
|
||
color: "var(--weekly-text, #333)", fontSize: "0.875rem",
|
||
};
|
||
|
||
const btnStyle = (primary?: boolean): React.CSSProperties => ({
|
||
padding: "8px 16px", borderRadius: "8px",
|
||
background: primary ? "var(--weekly-text, #333)" : "var(--weekly-bg, #fff)",
|
||
color: primary ? "var(--weekly-bg, #fff)" : "var(--weekly-text, #333)",
|
||
border: primary ? "none" : "1px solid var(--weekly-border, #e5e7eb)",
|
||
fontSize: "0.875rem", fontWeight: 500, cursor: "pointer",
|
||
display: "flex", alignItems: "center", gap: "6px",
|
||
});
|
||
|
||
// Calendar picker select for the form
|
||
const CalPicker = ({ label: pickerLabel, connId, calId, onChange }: {
|
||
label: string;
|
||
connId: string;
|
||
calId: string;
|
||
onChange: (connId: string, calId: string) => void;
|
||
}) => {
|
||
const value = connId && calId ? `${connId}::${calId}` : "";
|
||
return (
|
||
<div style={{ marginBottom: "12px" }}>
|
||
{label(pickerLabel)}
|
||
<select
|
||
value={value}
|
||
onChange={(e) => {
|
||
const [cid, calid] = e.target.value.split("::");
|
||
onChange(cid, calid);
|
||
}}
|
||
style={inputStyle}
|
||
>
|
||
<option value="">— Select calendar —</option>
|
||
{calOptions.map((opt) => (
|
||
<option key={`${opt.connId}::${opt.calId}`} value={`${opt.connId}::${opt.calId}`}>
|
||
[{opt.connLabel}] {opt.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
if (loading) return <p style={{ opacity: 0.5, fontSize: "0.875rem" }}>Loading sync rules…</p>;
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||
{/* Enable/Disable section header */}
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingBottom: "12px", borderBottom: "1px solid var(--weekly-border, #e5e7eb)" }}>
|
||
<div>
|
||
<h3 style={{ fontSize: "1rem", fontWeight: 700, marginBottom: "2px" }}>
|
||
{t.calendarSyncTitle || "Calendar Sync"}
|
||
</h3>
|
||
<p style={{ fontSize: "0.8rem", color: "var(--weekly-text-light, #888)", margin: 0 }}>
|
||
{t.calendarSyncDesc || "Sync events between connected calendar providers."}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={runSync}
|
||
disabled={syncing || rules.filter((r) => r.enabled).length === 0}
|
||
style={{
|
||
...btnStyle(),
|
||
opacity: syncing || rules.filter((r) => r.enabled).length === 0 ? 0.4 : 1,
|
||
}}
|
||
title={t.syncNow || "Sync Now"}
|
||
>
|
||
<RefreshCcw size={15} style={syncing ? { animation: "spin 1s linear infinite" } : {}} />
|
||
{syncing ? (t.syncing || "Syncing…") : (t.syncNow || "Sync Now")}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Status message */}
|
||
{msg && (
|
||
<div style={{
|
||
padding: "8px 12px", borderRadius: "7px", fontSize: "0.85rem",
|
||
background: msg.type === "success" ? "rgba(16,185,129,0.1)" : "rgba(239,68,68,0.1)",
|
||
color: msg.type === "success" ? "#059669" : "#dc2626",
|
||
border: `1px solid ${msg.type === "success" ? "#10b981" : "#ef4444"}`,
|
||
display: "flex", alignItems: "center", gap: "6px",
|
||
}}>
|
||
{msg.type === "success" ? <Check size={14} /> : <AlertCircle size={14} />}
|
||
{msg.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Sync results */}
|
||
{syncResults && (
|
||
<div style={{ padding: "10px 12px", borderRadius: "8px", border: "1px solid var(--weekly-border, #e5e7eb)", background: "var(--weekly-hover, #f9fafb)", fontSize: "0.8rem" }}>
|
||
<strong style={{ display: "block", marginBottom: "6px" }}>{t.syncResults || "Sync Results"}</strong>
|
||
{syncResults.length === 0
|
||
? <span style={{ opacity: 0.5 }}>{t.noRulesEnabled || "No enabled rules found."}</span>
|
||
: syncResults.map((r) => (
|
||
<div key={r.ruleId} style={{ marginBottom: "4px", display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||
<span style={{ fontWeight: 600 }}>{r.ruleName || "Rule"}</span>
|
||
{r.error
|
||
? <span style={{ color: "#dc2626" }}>Error: {r.error}</span>
|
||
: <>
|
||
{r.created > 0 && <span style={{ color: "#059669" }}>+{r.created}</span>}
|
||
{r.updated > 0 && <span style={{ color: "#0ea5e9" }}>~{r.updated}</span>}
|
||
{r.deleted > 0 && <span style={{ color: "#f59e0b" }}>−{r.deleted}</span>}
|
||
{r.created === 0 && r.updated === 0 && r.deleted === 0 && <span style={{ opacity: 0.5 }}>up to date</span>}
|
||
</>
|
||
}
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
)}
|
||
|
||
{/* No connections warning */}
|
||
{connections.length < 2 && (
|
||
<div style={{ padding: "10px 12px", borderRadius: "8px", background: "rgba(245,158,11,0.08)", border: "1px solid #f59e0b", fontSize: "0.85rem", color: "#b45309" }}>
|
||
<AlertCircle size={14} style={{ display: "inline", marginRight: "6px" }} />
|
||
{t.syncNeedsTwo || "You need at least two connected calendars to create a sync rule."}
|
||
</div>
|
||
)}
|
||
|
||
{/* Rules list */}
|
||
{rules.length > 0 && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||
{rules.map((rule) => {
|
||
const srcConn = connFor(rule.sourceConnectionId);
|
||
const tgtConn = connFor(rule.targetConnectionId);
|
||
return (
|
||
<div key={rule.id} style={{
|
||
padding: "12px 14px", borderRadius: "10px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
background: "var(--weekly-bg, #fff)",
|
||
display: "flex", flexDirection: "column", gap: "6px",
|
||
}}>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px" }}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
|
||
{/* Toggle */}
|
||
<button
|
||
onClick={() => toggleEnabled(rule)}
|
||
title={rule.enabled ? "Disable" : "Enable"}
|
||
style={{
|
||
width: "34px", height: "20px", borderRadius: "10px", border: "none",
|
||
background: rule.enabled ? "#22c55e" : "#d1d5db",
|
||
position: "relative", cursor: "pointer", flexShrink: 0, transition: "background 0.2s",
|
||
}}
|
||
>
|
||
<span style={{
|
||
position: "absolute", top: "3px",
|
||
left: rule.enabled ? "17px" : "3px",
|
||
width: "14px", height: "14px", borderRadius: "50%",
|
||
background: "#fff", transition: "left 0.2s",
|
||
}} />
|
||
</button>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontWeight: 600, fontSize: "0.875rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||
{rule.name || `${srcConn ? providerLabel(srcConn) : "?"} → ${tgtConn ? providerLabel(tgtConn) : "?"}`}
|
||
</div>
|
||
<div style={{ fontSize: "0.75rem", color: "var(--weekly-text-light, #888)", display: "flex", alignItems: "center", gap: "4px", marginTop: "2px" }}>
|
||
<span>{srcConn ? calendarLabel(srcConn, rule.sourceCalendarId) : rule.sourceCalendarId}</span>
|
||
{rule.direction === "two-way" ? <ArrowLeftRight size={11} /> : <ArrowRight size={11} />}
|
||
<span>{tgtConn ? calendarLabel(tgtConn, rule.targetCalendarId) : rule.targetCalendarId}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: "flex", gap: "4px", flexShrink: 0 }}>
|
||
<button onClick={() => openEditForm(rule)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)", padding: "4px" }} title="Edit">
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button onClick={() => deleteRule(rule.id)} style={{ background: "none", border: "none", cursor: "pointer", color: "#ef4444", padding: "4px" }} title="Delete">
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{rule.lastSyncedAt && (
|
||
<div style={{ fontSize: "0.7rem", color: "var(--weekly-text-light, #aaa)" }}>
|
||
Last synced: {new Date(rule.lastSyncedAt).toLocaleString()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{rules.length === 0 && !showForm && (
|
||
<p style={{ opacity: 0.5, fontSize: "0.875rem", textAlign: "center", padding: "16px 0" }}>
|
||
{t.noSyncRules || "No sync rules yet. Add one below."}
|
||
</p>
|
||
)}
|
||
|
||
{/* Add new rule button */}
|
||
{!showForm && connections.length >= 2 && (
|
||
<button onClick={openNewForm} style={{ ...btnStyle(), justifyContent: "center", padding: "10px 16px" }}>
|
||
<Plus size={15} />
|
||
{t.addSyncRule || "Add Sync Rule"}
|
||
</button>
|
||
)}
|
||
|
||
{/* Form */}
|
||
{showForm && (
|
||
<div style={{
|
||
padding: "16px", borderRadius: "12px",
|
||
border: "1px solid var(--weekly-border, #e5e7eb)",
|
||
background: "var(--weekly-hover, #f9fafb)",
|
||
display: "flex", flexDirection: "column", gap: "8px",
|
||
}}>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "4px" }}>
|
||
<strong style={{ fontSize: "0.9rem" }}>{editingId ? (t.editSyncRule || "Edit Rule") : (t.newSyncRule || "New Sync Rule")}</strong>
|
||
<button onClick={closeForm} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--weekly-text-light, #888)" }}>
|
||
<X size={16} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Rule name */}
|
||
<div>
|
||
{label(t.syncRuleName || "Rule Name (optional)")}
|
||
<input
|
||
type="text"
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
placeholder={t.syncRuleNamePlaceholder || "e.g. Work → Personal"}
|
||
style={inputStyle}
|
||
/>
|
||
</div>
|
||
|
||
{/* Direction */}
|
||
<div style={{ marginBottom: "4px" }}>
|
||
{label(t.syncDirection || "Direction")}
|
||
<div style={{ display: "flex", gap: "8px" }}>
|
||
{(["one-way", "two-way"] as const).map((d) => (
|
||
<button
|
||
key={d}
|
||
onClick={() => setForm({ ...form, direction: d })}
|
||
style={{
|
||
...btnStyle(form.direction === d),
|
||
flex: 1, justifyContent: "center",
|
||
}}
|
||
>
|
||
{d === "one-way" ? <><ArrowRight size={14} /> {t.oneWay || "One-way"}</> : <><ArrowLeftRight size={14} /> {t.twoWay || "Two-way"}</>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Source calendar */}
|
||
<CalPicker
|
||
label={t.sourceCalendar || "Source Calendar"}
|
||
connId={form.sourceConnectionId}
|
||
calId={form.sourceCalendarId}
|
||
onChange={(connId, calId) => setForm({ ...form, sourceConnectionId: connId, sourceCalendarId: calId })}
|
||
/>
|
||
|
||
{/* Target calendar */}
|
||
<CalPicker
|
||
label={t.targetCalendar || "Target Calendar"}
|
||
connId={form.targetConnectionId}
|
||
calId={form.targetCalendarId}
|
||
onChange={(connId, calId) => setForm({ ...form, targetConnectionId: connId, targetCalendarId: calId })}
|
||
/>
|
||
|
||
{/* Title prefix */}
|
||
<div>
|
||
{label(t.titlePrefix || "Title Prefix (optional)")}
|
||
<input
|
||
type="text"
|
||
value={form.titlePrefix}
|
||
onChange={(e) => setForm({ ...form, titlePrefix: e.target.value })}
|
||
placeholder={t.titlePrefixPlaceholder || "e.g. [Work] "}
|
||
style={inputStyle}
|
||
/>
|
||
</div>
|
||
|
||
{/* Options checkboxes */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "4px" }}>
|
||
{([
|
||
{ key: "syncDescription", label: t.syncDescription || "Sync description" },
|
||
{ key: "syncLocation", label: t.syncLocation || "Sync location" },
|
||
{ key: "syncRecurring", label: t.syncRecurring || "Include recurring events" },
|
||
] as const).map(({ key, label: optLabel }) => (
|
||
<label key={key} style={{ display: "flex", alignItems: "center", gap: "8px", fontSize: "0.875rem", cursor: "pointer" }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={form[key]}
|
||
onChange={(e) => setForm({ ...form, [key]: e.target.checked })}
|
||
style={{ width: "15px", height: "15px" }}
|
||
/>
|
||
{optLabel}
|
||
</label>
|
||
))}
|
||
</div>
|
||
|
||
{formError && (
|
||
<div style={{ fontSize: "0.8rem", color: "#dc2626", display: "flex", alignItems: "center", gap: "6px" }}>
|
||
<AlertCircle size={13} /> {formError}
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", gap: "8px", marginTop: "4px" }}>
|
||
<button onClick={saveForm} disabled={saving} style={{ ...btnStyle(true), flex: 1, justifyContent: "center", opacity: saving ? 0.6 : 1 }}>
|
||
{saving ? (t.saving || "Saving…") : (editingId ? (t.updateRule || "Update Rule") : (t.createRule || "Create Rule"))}
|
||
</button>
|
||
<button onClick={closeForm} style={{ ...btnStyle(), flexShrink: 0 }}>
|
||
{t.cancel || "Cancel"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|