← All changes
|
resources/js/components/email/EmailTemplatesList.tsx
+489
-41
3.0.8
→
3.0.17
View file →
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import React, { useState, useMemo, useCallback } from "react"; |
| 7 | 7 | import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| 8 | -import { __ } from "../../lib/i18n"; | |
| 8 | +import { __, sprintf } from "../../lib/i18n"; | |
| 9 | 9 | import { useToast } from "../ui/toast"; |
| 10 | 10 | import { Card, CardContent } from "../ui/card"; |
| 11 | 11 | import { Button } from "../ui/button"; |
| 12 | 12 | import { Input } from "../ui/input"; |
| @@ -47,8 +47,16 @@ | ||
| 47 | 47 | setEmailTemplateActive, |
| 48 | 48 | } from "../../api/email-automation-api"; |
| 49 | 49 | import { previewCoreEmailTemplate } from "../../api/settings-api"; |
| 50 | 50 | import { EmailPreviewModal } from "./EmailPreviewModal"; |
| 51 | +import { EmailOverrideCreateModal } from "./EmailOverrideCreateModal"; | |
| 52 | +import { SearchableSelect } from "../ui/searchable-select"; | |
| 53 | +import { | |
| 54 | + reorderEmailTemplateOverrides, | |
| 55 | + resolveEmailTemplatesForTrip, | |
| 56 | +} from "../../api/email-automation-api"; | |
| 57 | +import { describeTargets, useTripTargets } from "../../hooks/useTripTargets"; | |
| 58 | +import { ArrowDown, ArrowUp, Compass, Globe } from "lucide-react"; | |
| 51 | 59 | |
| 52 | 60 | const EMAIL_TEMPLATE_VISIBLE_COLUMNS_DEFAULT: Record<string, boolean> = { |
| 53 | 61 | name: true, |
| 54 | 62 | event: true, |
| @@ -155,9 +163,12 @@ | ||
| 155 | 163 | const [searchTerm, setSearchTerm] = useState(""); |
| 156 | 164 | const [categoryFilter, setCategoryFilter] = useState("all"); |
| 157 | 165 | const [recipientFilter, setRecipientFilter] = useState("all"); |
| 158 | 166 | const [eventFilter, setEventFilter] = useState("all"); |
| 159 | - const [statusFilter, setStatusFilter] = useState("all"); | |
| 167 | + // Default to the "Active" view so the list shows only in-use templates and | |
| 168 | + // disabled/unused system templates stay decluttered out of sight. The "All" | |
| 169 | + // and "Inactive" tabs remain one click away to review or re-enable them. | |
| 170 | + const [statusFilter, setStatusFilter] = useState("active"); | |
| 160 | 171 | const [showColumnsDropdown, setShowColumnsDropdown] = useState(false); |
| 161 | 172 | const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); |
| 162 | 173 | const [bulkAction, setBulkAction] = useState(""); |
| 163 | 174 | const [currentPage, setCurrentPage] = useState(1); |
| @@ -186,8 +197,37 @@ | ||
| 186 | 197 | JSON.stringify(newVisibleColumns), |
| 187 | 198 | ); |
| 188 | 199 | }; |
| 189 | 200 | |
| 201 | + // Trip-specific overrides (Pro Email Automation ≥ the build that sets this | |
| 202 | + // flag). Without it the tab is exactly the pre-override list. | |
| 203 | + const overridesSupported = | |
| 204 | + automationModuleActive && | |
| 205 | + !!(window as any).yatraAdmin?.emailTemplateOverridesEnabled; | |
| 206 | + const [section, setSection] = useState<"global" | "override">("global"); | |
| 207 | + const [parentFilter, setParentFilter] = useState(""); | |
| 208 | + const [asTrip, setAsTrip] = useState<number | null>(null); | |
| 209 | + const [overrideModal, setOverrideModal] = useState<{ | |
| 210 | + open: boolean; | |
| 211 | + parent: UnifiedEmailTemplate | null; | |
| 212 | + }>({ open: false, parent: null }); | |
| 213 | + const { options: tripTargetOptions } = useTripTargets(overridesSupported); | |
| 214 | + const tripOptions = useMemo( | |
| 215 | + () => | |
| 216 | + tripTargetOptions | |
| 217 | + .filter((o) => String(o.value).startsWith("trip:")) | |
| 218 | + .map((o) => ({ | |
| 219 | + id: Number(String(o.value).slice(5)), | |
| 220 | + label: o.label.replace(/^[^:]+: /, ""), | |
| 221 | + })), | |
| 222 | + [tripTargetOptions], | |
| 223 | + ); | |
| 224 | + const { data: resolution } = useQuery({ | |
| 225 | + queryKey: ["email-templates-resolve", asTrip], | |
| 226 | + queryFn: () => resolveEmailTemplatesForTrip(asTrip as number), | |
| 227 | + enabled: overridesSupported && !!asTrip, | |
| 228 | + }); | |
| 229 | + | |
| 190 | 230 | const { data: templatesData, isLoading: apiLoading } = useQuery({ |
| 191 | 231 | queryKey: ["email-templates"], |
| 192 | 232 | queryFn: () => fetchEmailTemplates(), |
| 193 | 233 | enabled: automationModuleActive, |
| @@ -208,12 +248,82 @@ | ||
| 208 | 248 | isModuleActive, |
| 209 | 249 | }); |
| 210 | 250 | }, [automationModuleActive, settingsBridge]); |
| 211 | 251 | |
| 212 | - const templates = automationModuleActive ? apiTemplates : localTemplates; | |
| 252 | + const allTemplates = automationModuleActive ? apiTemplates : localTemplates; | |
| 253 | + const overrideTemplates = useMemo( | |
| 254 | + () => (overridesSupported ? allTemplates.filter((t) => !!t.overrides) : []), | |
| 255 | + [allTemplates, overridesSupported], | |
| 256 | + ); | |
| 257 | + const globalTemplates = useMemo( | |
| 258 | + () => | |
| 259 | + overridesSupported | |
| 260 | + ? allTemplates.filter((t) => !t.overrides) | |
| 261 | + : allTemplates, | |
| 262 | + [allTemplates, overridesSupported], | |
| 263 | + ); | |
| 264 | + // The list the filters / table operate on: one section at a time. | |
| 265 | + const templates = | |
| 266 | + overridesSupported && section === "override" | |
| 267 | + ? overrideTemplates | |
| 268 | + : globalTemplates; | |
| 269 | + const globalByKey = useMemo(() => { | |
| 270 | + const m = new Map<string, UnifiedEmailTemplate>(); | |
| 271 | + globalTemplates.forEach((t) => m.set(t.template_key, t)); | |
| 272 | + return m; | |
| 273 | + }, [globalTemplates]); | |
| 274 | + const overridesByParent = useMemo(() => { | |
| 275 | + const m = new Map<string, UnifiedEmailTemplate[]>(); | |
| 276 | + overrideTemplates.forEach((t) => { | |
| 277 | + const k = t.overrides || ""; | |
| 278 | + m.set(k, [...(m.get(k) || []), t]); | |
| 279 | + }); | |
| 280 | + m.forEach((arr) => | |
| 281 | + arr.sort( | |
| 282 | + (a, b) => | |
| 283 | + (a.priority || 0) - (b.priority || 0) || Number(a.id) - Number(b.id), | |
| 284 | + ), | |
| 285 | + ); | |
| 286 | + return m; | |
| 287 | + }, [overrideTemplates]); | |
| 213 | 288 | |
| 214 | 289 | const isLoading = automationModuleActive ? apiLoading : false; |
| 215 | 290 | |
| 291 | + const reorderMutation = useMutation({ | |
| 292 | + mutationFn: ({ parentKey, ids }: { parentKey: string; ids: number[] }) => | |
| 293 | + reorderEmailTemplateOverrides(parentKey, ids), | |
| 294 | + onSuccess: () => { | |
| 295 | + queryClient.invalidateQueries({ queryKey: ["email-templates"] }); | |
| 296 | + queryClient.invalidateQueries({ queryKey: ["email-templates-resolve"] }); | |
| 297 | + }, | |
| 298 | + onError: (error: any) => { | |
| 299 | + showToast( | |
| 300 | + error?.message || __("Failed to reorder overrides", "yatra"), | |
| 301 | + "error", | |
| 302 | + ); | |
| 303 | + }, | |
| 304 | + }); | |
| 305 | + const moveOverride = (t: UnifiedEmailTemplate, delta: number) => { | |
| 306 | + const siblings = overridesByParent.get(t.overrides || "") || []; | |
| 307 | + const ids = siblings.map((x) => Number(x.id)); | |
| 308 | + const i = ids.indexOf(Number(t.id)); | |
| 309 | + const j = i + delta; | |
| 310 | + if (i < 0 || j < 0 || j >= ids.length) return; | |
| 311 | + [ids[i], ids[j]] = [ids[j], ids[i]]; | |
| 312 | + reorderMutation.mutate({ parentKey: t.overrides || "", ids }); | |
| 313 | + }; | |
| 314 | + /** "View as a trip": which row is sent for the chosen trip. */ | |
| 315 | + const sentForTrip = (t: UnifiedEmailTemplate): "hit" | "dim" | null => { | |
| 316 | + if (!asTrip || !resolution) return null; | |
| 317 | + if (t.overrides) { | |
| 318 | + return resolution.resolved[t.overrides]?.id === Number(t.id) | |
| 319 | + ? "hit" | |
| 320 | + : "dim"; | |
| 321 | + } | |
| 322 | + if (!resolution.overridable.includes(t.template_key)) return null; // not booking-bound: unaffected | |
| 323 | + return resolution.resolved[t.template_key] ? "dim" : "hit"; | |
| 324 | + }; | |
| 325 | + | |
| 216 | 326 | const toggleMutation = useMutation({ |
| 217 | 327 | mutationFn: async ({ |
| 218 | 328 | id, |
| 219 | 329 | is_active, |
| @@ -356,9 +466,13 @@ | ||
| 356 | 466 | categoryFilter === "all" || t.category === categoryFilter; |
| 357 | 467 | const matchesRecipient = |
| 358 | 468 | recipientFilter === "all" || |
| 359 | 469 | getEffectiveRecipientType(t) === recipientFilter; |
| 360 | - const matchesEvent = eventFilter === "all" || t.event_key === eventFilter; | |
| 470 | + const matchesEvent = | |
| 471 | + eventFilter === "all" || | |
| 472 | + (t.effective_event_key || t.event_key) === eventFilter; | |
| 473 | + const matchesParent = | |
| 474 | + !parentFilter || section !== "override" || t.overrides === parentFilter; | |
| 361 | 475 | const matchesStatus = |
| 362 | 476 | statusFilter === "all" || |
| 363 | 477 | (statusFilter === "active" && t.is_active) || |
| 364 | 478 | (statusFilter === "inactive" && !t.is_active); |
| @@ -366,9 +480,10 @@ | ||
| 366 | 480 | matchesSearch && |
| 367 | 481 | matchesCategory && |
| 368 | 482 | matchesRecipient && |
| 369 | 483 | matchesEvent && |
| 370 | - matchesStatus | |
| 484 | + matchesStatus && | |
| 485 | + matchesParent | |
| 371 | 486 | ); |
| 372 | 487 | }); |
| 373 | 488 | }, [ |
| 374 | 489 | templates, |
| @@ -376,8 +491,10 @@ | ||
| 376 | 491 | categoryFilter, |
| 377 | 492 | recipientFilter, |
| 378 | 493 | eventFilter, |
| 379 | 494 | statusFilter, |
| 495 | + parentFilter, | |
| 496 | + section, | |
| 380 | 497 | ]); |
| 381 | 498 | |
| 382 | 499 | const totalFilteredItems = filteredTemplates.length; |
| 383 | 500 | const totalPages = Math.ceil(totalFilteredItems / itemsPerPage); |
| @@ -387,9 +504,9 @@ | ||
| 387 | 504 | }, [filteredTemplates, currentPage, itemsPerPage]); |
| 388 | 505 | |
| 389 | 506 | useMemo(() => { |
| 390 | 507 | setCurrentPage(1); |
| 391 | - // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 508 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 392 | 509 | }, [searchTerm, categoryFilter, recipientFilter, eventFilter, statusFilter]); |
| 393 | 510 | |
| 394 | 511 | const handleLockedNavigate = useCallback(() => { |
| 395 | 512 | if (isProPluginActive()) { |
| @@ -470,12 +587,21 @@ | ||
| 470 | 587 | "admin.php?page=yatra&subpage=email-automation&tab=template&action=create"; |
| 471 | 588 | }; |
| 472 | 589 | |
| 473 | 590 | const handleBulkAction = () => { |
| 474 | - if (!bulkAction || selectedIds.length === 0) { | |
| 475 | - showToast(__("Please select templates and an action"), "error"); | |
| 591 | + // Report the input that is actually missing. The Apply button is disabled | |
| 592 | + // whenever nothing is selected, so this can only be reached with a non-empty | |
| 593 | + // selection — the combined message blamed the selection and left operators | |
| 594 | + // re-selecting rows that were already ticked. | |
| 595 | + if (selectedIds.length === 0) { | |
| 596 | + showToast(__("Please select at least one template.", "yatra"), "error"); | |
| 476 | 597 | return; |
| 477 | 598 | } |
| 599 | + | |
| 600 | + if (!bulkAction) { | |
| 601 | + showToast(__("Please choose an action to apply.", "yatra"), "error"); | |
| 602 | + return; | |
| 603 | + } | |
| 478 | 604 | if (bulkAction === "delete") { |
| 479 | 605 | bulkDeleteMutation.mutate(selectedIds); |
| 480 | 606 | } |
| 481 | 607 | }; |
| @@ -547,28 +673,161 @@ | ||
| 547 | 673 | > |
| 548 | 674 | {template.name} |
| 549 | 675 | </button> |
| 550 | 676 | )} |
| 551 | - {template.is_system ? ( | |
| 677 | + {template.overrides ? ( | |
| 552 | 678 | <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"> |
| 553 | - {__("System")} | |
| 679 | + {__("Override", "yatra")} | |
| 554 | 680 | </span> |
| 681 | + ) : template.is_system ? ( | |
| 682 | + <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"> | |
| 683 | + {overridesSupported ? __("Global", "yatra") : __("System")} | |
| 684 | + </span> | |
| 555 | 685 | ) : ( |
| 556 | 686 | <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"> |
| 557 | 687 | {__("Custom")} |
| 558 | 688 | </span> |
| 559 | 689 | )} |
| 690 | + {(() => { | |
| 691 | + const mark = sentForTrip(template); | |
| 692 | + const tripName = | |
| 693 | + tripOptions.find((t) => t.id === asTrip)?.label || ""; | |
| 694 | + if (mark === "hit") { | |
| 695 | + return ( | |
| 696 | + <span | |
| 697 | + className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300" | |
| 698 | + data-testid="sent-for-trip" | |
| 699 | + > | |
| 700 | + {sprintf(__("sent for %s", "yatra"), tripName)} | |
| 701 | + {template.overrides && | |
| 702 | + resolution?.resolved[template.overrides]?.reason | |
| 703 | + ? ` · ${resolution.resolved[template.overrides]?.reason}` | |
| 704 | + : ""} | |
| 705 | + </span> | |
| 706 | + ); | |
| 707 | + } | |
| 708 | + if (mark === "dim" && !template.overrides) { | |
| 709 | + const ov = resolution?.resolved[template.template_key]; | |
| 710 | + return ( | |
| 711 | + <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"> | |
| 712 | + {sprintf( | |
| 713 | + __("overridden for %1$s → %2$s", "yatra"), | |
| 714 | + tripName, | |
| 715 | + ov?.name || "", | |
| 716 | + )} | |
| 717 | + </span> | |
| 718 | + ); | |
| 719 | + } | |
| 720 | + return null; | |
| 721 | + })()} | |
| 722 | + {template.overrides && !template.is_active && ( | |
| 723 | + <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"> | |
| 724 | + {__("off — its trips get the global template", "yatra")} | |
| 725 | + </span> | |
| 726 | + )} | |
| 727 | + {template.overrides && | |
| 728 | + template.is_active && | |
| 729 | + !String(template.body || "").trim() && ( | |
| 730 | + <span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"> | |
| 731 | + {__( | |
| 732 | + "no body yet — its trips get the global template", | |
| 733 | + "yatra", | |
| 734 | + )} | |
| 735 | + </span> | |
| 736 | + )} | |
| 560 | 737 | </div> |
| 561 | 738 | </div> |
| 562 | 739 | ); |
| 563 | 740 | }, |
| 564 | 741 | }, |
| 742 | + ...(overridesSupported && section === "override" | |
| 743 | + ? [ | |
| 744 | + { | |
| 745 | + key: "overrides_of", | |
| 746 | + label: __("Overrides", "yatra"), | |
| 747 | + visible: true, | |
| 748 | + render: (template: UnifiedEmailTemplate) => { | |
| 749 | + const parent = globalByKey.get(template.overrides || ""); | |
| 750 | + const siblings = | |
| 751 | + overridesByParent.get(template.overrides || "") || []; | |
| 752 | + const pos = siblings.findIndex((x) => x.id === template.id) + 1; | |
| 753 | + return ( | |
| 754 | + <div> | |
| 755 | + <button | |
| 756 | + type="button" | |
| 757 | + className="text-blue-600 dark:text-blue-400 hover:underline font-medium text-left" | |
| 758 | + onClick={() => parent && handleEdit(parent)} | |
| 759 | + > | |
| 760 | + {parent?.name || template.overrides} | |
| 761 | + </button> | |
| 762 | + {siblings.length > 1 && ( | |
| 763 | + <div className="text-xs text-gray-400"> | |
| 764 | + {sprintf( | |
| 765 | + __("priority %1$d of %2$d", "yatra"), | |
| 766 | + pos, | |
| 767 | + siblings.length, | |
| 768 | + )} | |
| 769 | + </div> | |
| 770 | + )} | |
| 771 | + </div> | |
| 772 | + ); | |
| 773 | + }, | |
| 774 | + }, | |
| 775 | + { | |
| 776 | + key: "applies_to", | |
| 777 | + label: __("Applies to", "yatra"), | |
| 778 | + visible: true, | |
| 779 | + render: (template: UnifiedEmailTemplate) => ( | |
| 780 | + <span className="text-sm text-purple-700 dark:text-purple-300"> | |
| 781 | + {describeTargets(template.targets, tripTargetOptions) || "—"} | |
| 782 | + </span> | |
| 783 | + ), | |
| 784 | + }, | |
| 785 | + ] | |
| 786 | + : []), | |
| 787 | + ...(overridesSupported && section === "global" | |
| 788 | + ? [ | |
| 789 | + { | |
| 790 | + key: "overrides_count", | |
| 791 | + label: __("Overrides", "yatra"), | |
| 792 | + visible: true, | |
| 793 | + render: (template: UnifiedEmailTemplate) => { | |
| 794 | + const n = (overridesByParent.get(template.template_key) || []) | |
| 795 | + .length; | |
| 796 | + if (!n) { | |
| 797 | + return ( | |
| 798 | + <span className="text-xs text-gray-400"> | |
| 799 | + {template.overridable ? __("none", "yatra") : "—"} | |
| 800 | + </span> | |
| 801 | + ); | |
| 802 | + } | |
| 803 | + return ( | |
| 804 | + <button | |
| 805 | + type="button" | |
| 806 | + className="text-sm text-purple-700 dark:text-purple-300 hover:underline inline-flex items-center gap-1" | |
| 807 | + onClick={() => { | |
| 808 | + setSection("override"); | |
| 809 | + setParentFilter(template.template_key); | |
| 810 | + }} | |
| 811 | + data-testid="overrides-count" | |
| 812 | + > | |
| 813 | + <Compass className="w-3.5 h-3.5" /> | |
| 814 | + {n === 1 | |
| 815 | + ? __("1 override →", "yatra") | |
| 816 | + : sprintf(__("%d overrides →", "yatra"), n)} | |
| 817 | + </button> | |
| 818 | + ); | |
| 819 | + }, | |
| 820 | + }, | |
| 821 | + ] | |
| 822 | + : []), | |
| 565 | 823 | { |
| 566 | 824 | key: "event", |
| 567 | 825 | label: __("Event"), |
| 568 | 826 | visible: visibleColumns.event, |
| 569 | 827 | render: (template: UnifiedEmailTemplate) => { |
| 570 | - const eventInfo = events.find((e: any) => e.key === template.event_key); | |
| 828 | + const eventKey = template.effective_event_key || template.event_key; | |
| 829 | + const eventInfo = events.find((e: any) => e.key === eventKey); | |
| 571 | 830 | return ( |
| 572 | 831 | <span |
| 573 | 832 | className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400 cursor-help" |
| 574 | 833 | title={eventInfo?.description || ""} |
| @@ -573,9 +832,9 @@ | ||
| 573 | 832 | className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400 cursor-help" |
| 574 | 833 | title={eventInfo?.description || ""} |
| 575 | 834 | > |
| 576 | 835 | <Zap className="w-3 h-3" /> |
| 577 | - {formatEventKey(template.event_key)} | |
| 836 | + {formatEventKey(eventKey)} | |
| 578 | 837 | </span> |
| 579 | 838 | ); |
| 580 | 839 | }, |
| 581 | 840 | }, |
| @@ -594,9 +853,13 @@ | ||
| 594 | 853 | }, |
| 595 | 854 | { |
| 596 | 855 | key: "body_preview", |
| 597 | 856 | label: __("Body"), |
| 598 | - visible: visibleColumns.body_preview, | |
| 857 | + // Hidden in the Override section: those rows are about targeting, so the | |
| 858 | + // Template / Overrides / Applies to columns get the room instead. | |
| 859 | + visible: | |
| 860 | + visibleColumns.body_preview && | |
| 861 | + !(overridesSupported && section === "override"), | |
| 599 | 862 | render: (template: UnifiedEmailTemplate) => { |
| 600 | 863 | const preview = plainTextEmailPreview(template.body || ""); |
| 601 | 864 | return ( |
| 602 | 865 | <span |
| @@ -610,9 +873,13 @@ | ||
| 610 | 873 | }, |
| 611 | 874 | { |
| 612 | 875 | key: "description", |
| 613 | 876 | label: __("Description"), |
| 614 | - visible: visibleColumns.description, | |
| 877 | + // Hidden in the Override section: those rows are about targeting, so the | |
| 878 | + // Template / Overrides / Applies to columns get the room instead. | |
| 879 | + visible: | |
| 880 | + visibleColumns.description && | |
| 881 | + !(overridesSupported && section === "override"), | |
| 615 | 882 | render: (template: UnifiedEmailTemplate) => ( |
| 616 | 883 | <span className="text-sm text-gray-600 dark:text-gray-400 line-clamp-1"> |
| 617 | 884 | {template.description || "-"} |
| 618 | 885 | </span> |
| @@ -620,9 +887,13 @@ | ||
| 620 | 887 | }, |
| 621 | 888 | { |
| 622 | 889 | key: "category", |
| 623 | 890 | label: __("Category"), |
| 624 | - visible: visibleColumns.category, | |
| 891 | + // Hidden in the Override section: those rows are about targeting, so the | |
| 892 | + // Template / Overrides / Applies to columns get the room instead. | |
| 893 | + visible: | |
| 894 | + visibleColumns.category && | |
| 895 | + !(overridesSupported && section === "override"), | |
| 625 | 896 | render: (template: UnifiedEmailTemplate) => { |
| 626 | 897 | const CategoryIcon = categoryIcons[template.category] || Mail; |
| 627 | 898 | return ( |
| 628 | 899 | <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"> |
| @@ -635,9 +906,13 @@ | ||
| 635 | 906 | }, |
| 636 | 907 | { |
| 637 | 908 | key: "recipient_type", |
| 638 | 909 | label: __("Recipient"), |
| 639 | - visible: visibleColumns.recipient_type, | |
| 910 | + // Hidden in the Override section: those rows are about targeting, so the | |
| 911 | + // Template / Overrides / Applies to columns get the room instead. | |
| 912 | + visible: | |
| 913 | + visibleColumns.recipient_type && | |
| 914 | + !(overridesSupported && section === "override"), | |
| 640 | 915 | render: (template: UnifiedEmailTemplate) => { |
| 641 | 916 | const effectiveRecipient = getEffectiveRecipientType(template); |
| 642 | 917 | const toEmail = template.to_email || ""; |
| 643 | 918 | return ( |
| @@ -693,8 +968,43 @@ | ||
| 693 | 968 | }, |
| 694 | 969 | ]; |
| 695 | 970 | |
| 696 | 971 | const actions = [ |
| 972 | + ...(overridesSupported | |
| 973 | + ? [ | |
| 974 | + { | |
| 975 | + key: "add_override", | |
| 976 | + label: __("Add override…", "yatra"), | |
| 977 | + icon: <Compass className="w-4 h-4" />, | |
| 978 | + onClick: (template: UnifiedEmailTemplate) => | |
| 979 | + setOverrideModal({ open: true, parent: template }), | |
| 980 | + condition: (template: UnifiedEmailTemplate) => | |
| 981 | + !template.overrides && | |
| 982 | + !!template.overridable && | |
| 983 | + isApiTemplate(template), | |
| 984 | + }, | |
| 985 | + { | |
| 986 | + key: "override_up", | |
| 987 | + label: __("Higher priority", "yatra"), | |
| 988 | + icon: <ArrowUp className="w-4 h-4" />, | |
| 989 | + onClick: (template: UnifiedEmailTemplate) => | |
| 990 | + moveOverride(template, -1), | |
| 991 | + condition: (template: UnifiedEmailTemplate) => | |
| 992 | + !!template.overrides && | |
| 993 | + (overridesByParent.get(template.overrides) || []).length > 1, | |
| 994 | + }, | |
| 995 | + { | |
| 996 | + key: "override_down", | |
| 997 | + label: __("Lower priority", "yatra"), | |
| 998 | + icon: <ArrowDown className="w-4 h-4" />, | |
| 999 | + onClick: (template: UnifiedEmailTemplate) => | |
| 1000 | + moveOverride(template, 1), | |
| 1001 | + condition: (template: UnifiedEmailTemplate) => | |
| 1002 | + !!template.overrides && | |
| 1003 | + (overridesByParent.get(template.overrides) || []).length > 1, | |
| 1004 | + }, | |
| 1005 | + ] | |
| 1006 | + : []), | |
| 697 | 1007 | { |
| 698 | 1008 | key: "preview", |
| 699 | 1009 | label: __("Preview", "yatra"), |
| 700 | 1010 | icon: <Eye className="w-4 h-4" />, |
| @@ -772,17 +1082,109 @@ | ||
| 772 | 1082 | )} |
| 773 | 1083 | </p> |
| 774 | 1084 | )} |
| 775 | 1085 | |
| 1086 | + {overridesSupported && ( | |
| 1087 | + <div className="space-y-2" data-testid="template-sections"> | |
| 1088 | + <div className="inline-flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden"> | |
| 1089 | + <button | |
| 1090 | + type="button" | |
| 1091 | + onClick={() => { | |
| 1092 | + setSection("global"); | |
| 1093 | + setParentFilter(""); | |
| 1094 | + }} | |
| 1095 | + aria-pressed={section === "global"} | |
| 1096 | + className={`inline-flex items-center gap-2 px-4 h-10 text-sm font-medium ${section === "global" ? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" : "bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-300"}`} | |
| 1097 | + data-testid="section-global" | |
| 1098 | + > | |
| 1099 | + <Globe className="w-4 h-4" /> | |
| 1100 | + {__("Global templates", "yatra")} | |
| 1101 | + <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"> | |
| 1102 | + {globalTemplates.length} | |
| 1103 | + </span> | |
| 1104 | + </button> | |
| 1105 | + <button | |
| 1106 | + type="button" | |
| 1107 | + onClick={() => setSection("override")} | |
| 1108 | + aria-pressed={section === "override"} | |
| 1109 | + className={`inline-flex items-center gap-2 px-4 h-10 text-sm font-medium border-l border-gray-300 dark:border-gray-600 ${section === "override" ? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300" : "bg-white text-gray-600 dark:bg-gray-800 dark:text-gray-300"}`} | |
| 1110 | + data-testid="section-override" | |
| 1111 | + > | |
| 1112 | + <Compass className="w-4 h-4" /> | |
| 1113 | + {__("Override templates", "yatra")} | |
| 1114 | + <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"> | |
| 1115 | + {overrideTemplates.length} | |
| 1116 | + </span> | |
| 1117 | + </button> | |
| 1118 | + </div> | |
| 1119 | + <p className="text-sm text-gray-600 dark:text-gray-400"> | |
| 1120 | + {section === "global" | |
| 1121 | + ? __( | |
| 1122 | + "One template per event, sent to every trip unless an override applies. Editing a global template never changes its overrides.", | |
| 1123 | + "yatra", | |
| 1124 | + ) | |
| 1125 | + : __( | |
| 1126 | + "Trip-specific versions of a global template. Same event and merge tags as the template they override; used only for bookings on the trips they name. Everything else keeps the global template.", | |
| 1127 | + "yatra", | |
| 1128 | + )} | |
| 1129 | + </p> | |
| 1130 | + </div> | |
| 1131 | + )} | |
| 1132 | + | |
| 776 | 1133 | {automationModuleActive && ( |
| 777 | - <div className="flex justify-end"> | |
| 778 | - <Button | |
| 779 | - onClick={handleCreate} | |
| 780 | - className="bg-blue-600 hover:bg-blue-700 text-white" | |
| 781 | - > | |
| 782 | - <Plus className="w-4 h-4 mr-2" /> | |
| 783 | - {__("Create Template")} | |
| 784 | - </Button> | |
| 1134 | + <div className="flex flex-wrap items-center justify-between gap-3"> | |
| 1135 | + {overridesSupported ? ( | |
| 1136 | + <div className="flex flex-wrap items-center gap-2 text-sm"> | |
| 1137 | + <span className="text-gray-500"> | |
| 1138 | + {__("View as a trip:", "yatra")} | |
| 1139 | + </span> | |
| 1140 | + <div className="w-72" data-testid="view-as-trip"> | |
| 1141 | + <SearchableSelect | |
| 1142 | + value={asTrip ? String(asTrip) : ""} | |
| 1143 | + onChange={(value) => setAsTrip(value ? Number(value) : null)} | |
| 1144 | + options={tripOptions.map((t) => ({ | |
| 1145 | + value: String(t.id), | |
| 1146 | + label: t.label, | |
| 1147 | + }))} | |
| 1148 | + placeholder={__("— none (show everything) —", "yatra")} | |
| 1149 | + searchPlaceholder={__("Search trips…", "yatra")} | |
| 1150 | + /> | |
| 1151 | + </div> | |
| 1152 | + <span className="text-xs text-gray-400"> | |
| 1153 | + {asTrip | |
| 1154 | + ? __( | |
| 1155 | + "Highlighted rows are exactly what this trip's customers receive.", | |
| 1156 | + "yatra", | |
| 1157 | + ) | |
| 1158 | + : __( | |
| 1159 | + "Pick a trip to highlight exactly what that trip's customers receive.", | |
| 1160 | + "yatra", | |
| 1161 | + )} | |
| 1162 | + </span> | |
| 1163 | + </div> | |
| 1164 | + ) : ( | |
| 1165 | + <span /> | |
| 1166 | + )} | |
| 1167 | + <div className="flex items-center gap-2"> | |
| 1168 | + {overridesSupported && section === "override" ? ( | |
| 1169 | + <Button | |
| 1170 | + onClick={() => setOverrideModal({ open: true, parent: null })} | |
| 1171 | + className="bg-blue-600 hover:bg-blue-700 text-white" | |
| 1172 | + data-testid="add-override" | |
| 1173 | + > | |
| 1174 | + <Plus className="w-4 h-4 mr-2" /> | |
| 1175 | + {__("Add override", "yatra")} | |
| 1176 | + </Button> | |
| 1177 | + ) : ( | |
| 1178 | + <Button | |
| 1179 | + onClick={handleCreate} | |
| 1180 | + className="bg-blue-600 hover:bg-blue-700 text-white" | |
| 1181 | + > | |
| 1182 | + <Plus className="w-4 h-4 mr-2" /> | |
| 1183 | + {__("Create Template")} | |
| 1184 | + </Button> | |
| 1185 | + )} | |
| 1186 | + </div> | |
| 785 | 1187 | </div> |
| 786 | 1188 | )} |
| 787 | 1189 | |
| 788 | 1190 | <Card> |
| @@ -799,20 +1201,42 @@ | ||
| 799 | 1201 | /> |
| 800 | 1202 | </div> |
| 801 | 1203 | </div> |
| 802 | 1204 | <div className="lg:col-span-2"> |
| 803 | - <Select | |
| 804 | - value={categoryFilter} | |
| 805 | - onChange={(e) => setCategoryFilter(e.target.value)} | |
| 806 | - className="w-full" | |
| 807 | - > | |
| 808 | - <option value="all">{__("All Categories")}</option> | |
| 809 | - {categories.map((cat: string) => ( | |
| 810 | - <option key={cat} value={cat}> | |
| 811 | - {cat.charAt(0).toUpperCase() + cat.slice(1)} | |
| 812 | - </option> | |
| 813 | - ))} | |
| 814 | - </Select> | |
| 1205 | + {overridesSupported && section === "override" ? ( | |
| 1206 | + <Select | |
| 1207 | + value={parentFilter} | |
| 1208 | + onChange={(e) => setParentFilter(e.target.value)} | |
| 1209 | + className="w-full" | |
| 1210 | + data-testid="parent-filter" | |
| 1211 | + > | |
| 1212 | + <option value="">{__("Overrides of: any", "yatra")}</option> | |
| 1213 | + {globalTemplates | |
| 1214 | + .filter( | |
| 1215 | + (g) => | |
| 1216 | + (overridesByParent.get(g.template_key) || []).length > | |
| 1217 | + 0, | |
| 1218 | + ) | |
| 1219 | + .map((g) => ( | |
| 1220 | + <option key={g.template_key} value={g.template_key}> | |
| 1221 | + {sprintf(__("Overrides of: %s", "yatra"), g.name)} | |
| 1222 | + </option> | |
| 1223 | + ))} | |
| 1224 | + </Select> | |
| 1225 | + ) : ( | |
| 1226 | + <Select | |
| 1227 | + value={categoryFilter} | |
| 1228 | + onChange={(e) => setCategoryFilter(e.target.value)} | |
| 1229 | + className="w-full" | |
| 1230 | + > | |
| 1231 | + <option value="all">{__("All Categories")}</option> | |
| 1232 | + {categories.map((cat: string) => ( | |
| 1233 | + <option key={cat} value={cat}> | |
| 1234 | + {cat.charAt(0).toUpperCase() + cat.slice(1)} | |
| 1235 | + </option> | |
| 1236 | + ))} | |
| 1237 | + </Select> | |
| 1238 | + )} | |
| 815 | 1239 | </div> |
| 816 | 1240 | <div className="lg:col-span-2"> |
| 817 | 1241 | <Select |
| 818 | 1242 | value={recipientFilter} |
| @@ -882,13 +1306,28 @@ | ||
| 882 | 1306 | data={paginatedTemplates} |
| 883 | 1307 | columns={columns} |
| 884 | 1308 | actions={actions} |
| 885 | 1309 | isLoading={isLoading} |
| 886 | - emptyText={__("No templates found")} | |
| 887 | - emptyDescription={__( | |
| 888 | - "Create your first email template to get started.", | |
| 889 | - )} | |
| 890 | - onCreateClick={automationModuleActive ? handleCreate : undefined} | |
| 1310 | + emptyText={ | |
| 1311 | + overridesSupported && section === "override" | |
| 1312 | + ? __("No overrides yet", "yatra") | |
| 1313 | + : __("No templates found") | |
| 1314 | + } | |
| 1315 | + emptyDescription={ | |
| 1316 | + overridesSupported && section === "override" | |
| 1317 | + ? __( | |
| 1318 | + "Every trip gets the global templates. Use “Add override” here or “Add override…” on a global template.", | |
| 1319 | + "yatra", | |
| 1320 | + ) | |
| 1321 | + : __("Create your first email template to get started.") | |
| 1322 | + } | |
| 1323 | + onCreateClick={ | |
| 1324 | + automationModuleActive | |
| 1325 | + ? overridesSupported && section === "override" | |
| 1326 | + ? () => setOverrideModal({ open: true, parent: null }) | |
| 1327 | + : handleCreate | |
| 1328 | + : undefined | |
| 1329 | + } | |
| 891 | 1330 | getItemId={(template: UnifiedEmailTemplate) => template.id} |
| 892 | 1331 | capability="yatra_manage_emails" |
| 893 | 1332 | skeletonRows={5} |
| 894 | 1333 | selectedItemIds={selectedIds} |
| @@ -913,8 +1352,17 @@ | ||
| 913 | 1352 | onPageChange={setCurrentPage} |
| 914 | 1353 | itemName={__("templates")} |
| 915 | 1354 | /> |
| 916 | 1355 | </div> |
| 1356 | + )} | |
| 1357 | + | |
| 1358 | + {overridesSupported && ( | |
| 1359 | + <EmailOverrideCreateModal | |
| 1360 | + isOpen={overrideModal.open} | |
| 1361 | + onClose={() => setOverrideModal({ open: false, parent: null })} | |
| 1362 | + globals={globalTemplates.filter((g) => !!g.overridable)} | |
| 1363 | + parent={overrideModal.parent} | |
| 1364 | + /> | |
| 917 | 1365 | )} |
| 918 | 1366 | |
| 919 | 1367 | <EmailPreviewModal |
| 920 | 1368 | open={previewOpen && Boolean(previewPayload)} |