@consilioweb/payload-support 5.0.0 → 6.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +249 -22
- package/dist/components/RichTextEditor/index.js +1 -1
- package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
- package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
- package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.js +10 -8
- package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
- package/dist/components/TicketConversation/index.d.ts +10 -0
- package/dist/components/TicketConversation/index.js +25 -7
- package/dist/index.cjs +306 -60
- package/dist/index.d.cts +107 -38
- package/dist/index.d.ts +107 -38
- package/dist/index.js +300 -59
- package/dist/styles/BillingView.module.scss +11 -11
- package/dist/styles/ChatView.module.scss +7 -7
- package/dist/styles/CommandPalette.module.scss +1 -1
- package/dist/styles/CrmView.module.scss +9 -9
- package/dist/styles/EmailTracking.module.scss +6 -6
- package/dist/styles/ImportConversation.module.scss +5 -5
- package/dist/styles/Logs.module.scss +5 -5
- package/dist/styles/NewTicket.module.scss +2 -2
- package/dist/styles/PendingEmails.module.scss +13 -13
- package/dist/styles/SupportDashboard.module.scss +24 -8
- package/dist/styles/TicketDetail.module.scss +20 -20
- package/dist/styles/TicketInbox.module.scss +6 -6
- package/dist/styles/TicketingSettings.module.scss +10 -10
- package/dist/styles/TimeDashboard.module.scss +6 -6
- package/dist/styles/_tokens.scss +3 -1
- package/dist/utils/db.js +20 -0
- package/dist/utils/readSettings.js +150 -0
- package/dist/views/BillingView/client.js +15 -8
- package/dist/views/ChatView/client.js +2 -0
- package/dist/views/CrmView/client.js +2 -0
- package/dist/views/EmailTrackingView/client.js +23 -11
- package/dist/views/ImportConversationView/client.js +1 -0
- package/dist/views/LogsView/client.js +1 -1
- package/dist/views/NewTicketView/client.js +20 -13
- package/dist/views/PendingEmailsView/client.js +9 -3
- package/dist/views/SupportDashboardView/client.js +17 -19
- package/dist/views/TicketDetailView/client.js +23 -11
- package/dist/views/TicketInboxView/client.js +52 -26
- package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/dist/views/TicketingSettingsView/client.js +44 -26
- package/dist/views/TimeDashboardView/client.js +10 -7
- package/dist/views/shared/ErrorBoundary.d.ts +36 -1
- package/dist/views/shared/ErrorBoundary.js +59 -28
- package/dist/views/shared/adminTokens.d.ts +15 -5
- package/dist/views/shared/adminTokens.js +23 -7
- package/dist/views/shared/icons.d.ts +49 -0
- package/dist/views/shared/icons.js +92 -0
- package/dist/views/shared/locales/en.json +15 -1
- package/dist/views/shared/locales/fr.json +15 -1
- package/package.json +9 -4
- package/scripts/copy-subpath-types.mjs +103 -0
- package/scripts/uninstall-data.mjs +178 -0
- package/scripts/uninstall.mjs +184 -0
- package/scripts/verify-dist-imports.mjs +84 -0
- package/src/collections/Tickets.ts +7 -0
- package/src/collections/TimeEntries.ts +11 -3
- package/src/components/RichTextEditor/index.tsx +1 -1
- package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
- package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
- package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
- package/src/components/TicketConversation/index.tsx +41 -7
- package/src/endpoints/delete-account.ts +191 -47
- package/src/endpoints/export-data.ts +104 -4
- package/src/endpoints/purge-logs.ts +6 -15
- package/src/index.ts +2 -0
- package/src/plugin.ts +51 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/profile/page.tsx +3 -2
- package/src/portal/icons.tsx +91 -0
- package/src/styles/BillingView.module.scss +11 -11
- package/src/styles/ChatView.module.scss +7 -7
- package/src/styles/CommandPalette.module.scss +1 -1
- package/src/styles/CrmView.module.scss +9 -9
- package/src/styles/EmailTracking.module.scss +6 -6
- package/src/styles/ImportConversation.module.scss +5 -5
- package/src/styles/Logs.module.scss +5 -5
- package/src/styles/NewTicket.module.scss +2 -2
- package/src/styles/PendingEmails.module.scss +13 -13
- package/src/styles/SupportDashboard.module.scss +24 -8
- package/src/styles/TicketDetail.module.scss +20 -20
- package/src/styles/TicketInbox.module.scss +6 -6
- package/src/styles/TicketingSettings.module.scss +10 -10
- package/src/styles/TimeDashboard.module.scss +6 -6
- package/src/styles/_tokens.scss +3 -1
- package/src/types.ts +20 -0
- package/src/utils/retention.ts +111 -0
- package/src/utils/slugs.ts +15 -0
- package/src/views/BillingView/client.tsx +17 -8
- package/src/views/ChatView/client.tsx +2 -0
- package/src/views/CrmView/client.tsx +2 -0
- package/src/views/EmailTrackingView/client.tsx +18 -7
- package/src/views/ImportConversationView/client.tsx +1 -0
- package/src/views/LogsView/client.tsx +1 -1
- package/src/views/NewTicketView/client.tsx +22 -13
- package/src/views/PendingEmailsView/client.tsx +9 -3
- package/src/views/SupportDashboardView/client.tsx +11 -5
- package/src/views/TicketDetailView/client.tsx +25 -11
- package/src/views/TicketInboxView/client.tsx +47 -7
- package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/src/views/TicketingSettingsView/client.tsx +201 -144
- package/src/views/TimeDashboardView/client.tsx +10 -7
- package/src/views/shared/ErrorBoundary.tsx +95 -31
- package/src/views/shared/adminTokens.ts +28 -11
- package/src/views/shared/icons.tsx +146 -0
- package/src/views/shared/locales/en.json +15 -1
- package/src/views/shared/locales/fr.json +15 -1
- package/src/types/lucide-react.d.ts +0 -42
|
@@ -34,6 +34,11 @@ export function TimeTrackingPanel({
|
|
|
34
34
|
duration, setDuration, timeDescription, setTimeDescription,
|
|
35
35
|
handleAddTime, addingTime, timeSuccess,
|
|
36
36
|
}: TimeTrackingPanelProps) {
|
|
37
|
+
// The two `<label>`s of the manual entry row exist but were not associated
|
|
38
|
+
// with anything. useId, never a literal: the panel is one per ticket screen
|
|
39
|
+
// but the pattern has to hold if it is ever mounted twice.
|
|
40
|
+
const minutesId = React.useId()
|
|
41
|
+
const workDescriptionId = React.useId()
|
|
37
42
|
const totalH = Math.floor(totalMinutes / 60)
|
|
38
43
|
const totalM = totalMinutes % 60
|
|
39
44
|
|
|
@@ -63,6 +68,7 @@ export function TimeTrackingPanel({
|
|
|
63
68
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
64
69
|
{[-5, -1, 1, 5, 15, 30].map((m) => (
|
|
65
70
|
<button
|
|
71
|
+
type="button"
|
|
66
72
|
key={m}
|
|
67
73
|
onClick={() => setTimerSeconds((p) => Math.max(0, p + m * 60))}
|
|
68
74
|
style={{ width: Math.abs(m) >= 15 ? '32px' : '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', background: 'white', cursor: 'pointer', fontSize: Math.abs(m) >= 15 ? '12px' : '14px', fontWeight: 700, color: '#64748b' }}
|
|
@@ -75,28 +81,30 @@ export function TimeTrackingPanel({
|
|
|
75
81
|
)}
|
|
76
82
|
|
|
77
83
|
{!timerRunning && timerSeconds === 0 && (
|
|
78
|
-
<button onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
84
|
+
<button type="button" onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
79
85
|
▶ Démarrer
|
|
80
86
|
</button>
|
|
81
87
|
)}
|
|
82
88
|
{timerRunning && (
|
|
83
|
-
<button onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
89
|
+
<button type="button" onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
|
84
90
|
⏸ Pause
|
|
85
91
|
</button>
|
|
86
92
|
)}
|
|
87
93
|
{!timerRunning && timerSeconds > 0 && (
|
|
88
94
|
<>
|
|
89
|
-
<button onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
|
|
95
|
+
<button type="button" onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
|
|
90
96
|
▶ Reprendre
|
|
91
97
|
</button>
|
|
92
98
|
<input
|
|
93
99
|
type="text"
|
|
100
|
+
aria-label="Description du temps en cours"
|
|
94
101
|
value={timerDescription}
|
|
95
102
|
onChange={(e) => setTimerDescription(e.target.value)}
|
|
96
103
|
placeholder="Description..."
|
|
97
104
|
style={{ ...s.input, fontSize: '12px', flex: 1, minWidth: '120px' }}
|
|
98
105
|
/>
|
|
99
106
|
<button
|
|
107
|
+
type="button"
|
|
100
108
|
onClick={handleTimerSave}
|
|
101
109
|
disabled={addingTime || timerSeconds < 60}
|
|
102
110
|
style={{ ...s.btn('#16a34a', addingTime || timerSeconds < 60), fontSize: '12px', padding: '6px 14px' }}
|
|
@@ -104,7 +112,7 @@ export function TimeTrackingPanel({
|
|
|
104
112
|
>
|
|
105
113
|
💾 {Math.round(timerSeconds / 60)} min
|
|
106
114
|
</button>
|
|
107
|
-
<button onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
|
|
115
|
+
<button type="button" onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
|
|
108
116
|
✕
|
|
109
117
|
</button>
|
|
110
118
|
</>
|
|
@@ -119,14 +127,14 @@ export function TimeTrackingPanel({
|
|
|
119
127
|
{/* Manual time entry */}
|
|
120
128
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: '14px' }}>
|
|
121
129
|
<div>
|
|
122
|
-
<label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
|
|
123
|
-
<input type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
|
|
130
|
+
<label htmlFor={minutesId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
|
|
131
|
+
<input id={minutesId} type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
|
|
124
132
|
</div>
|
|
125
133
|
<div style={{ flex: 1 }}>
|
|
126
|
-
<label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
|
|
127
|
-
<input type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
|
|
134
|
+
<label htmlFor={workDescriptionId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
|
|
135
|
+
<input id={workDescriptionId} type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
|
|
128
136
|
</div>
|
|
129
|
-
<button onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
|
|
137
|
+
<button type="button" onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
|
|
130
138
|
{addingTime ? '...' : '+ Temps'}
|
|
131
139
|
</button>
|
|
132
140
|
{timeSuccess && <span style={{ fontSize: '12px', color: '#16a34a', fontWeight: 600 }}>{timeSuccess}</span>}
|
|
@@ -24,9 +24,10 @@ import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
|
|
|
24
24
|
import { useFeatures } from './hooks/useFeatures'
|
|
25
25
|
import { SkeletonText } from './SkeletonText'
|
|
26
26
|
import { RewriteDropdown } from './RewriteDropdown'
|
|
27
|
+
import { AdminErrorBoundary } from '../../views/shared/ErrorBoundary'
|
|
27
28
|
import '../../styles/theme.css'
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const TicketConversationInner: React.FC = () => {
|
|
30
31
|
const { id } = useDocumentIdFromUrl()
|
|
31
32
|
const { features } = useFeatures()
|
|
32
33
|
const [messages, setMessages] = useState<Message[]>([])
|
|
@@ -416,6 +417,7 @@ const TicketConversation: React.FC = () => {
|
|
|
416
417
|
<div style={{ flex: 1 }}>
|
|
417
418
|
<input
|
|
418
419
|
type="text"
|
|
420
|
+
aria-label="Rechercher dans la conversation"
|
|
419
421
|
value={searchQuery}
|
|
420
422
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
421
423
|
placeholder="Rechercher..."
|
|
@@ -442,6 +444,7 @@ const TicketConversation: React.FC = () => {
|
|
|
442
444
|
<>
|
|
443
445
|
{showCollapse && hiddenCount > 0 && (
|
|
444
446
|
<button
|
|
447
|
+
type="button"
|
|
445
448
|
onClick={() => setMessagesCollapsed(false)}
|
|
446
449
|
style={{
|
|
447
450
|
background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
|
|
@@ -454,6 +457,7 @@ const TicketConversation: React.FC = () => {
|
|
|
454
457
|
)}
|
|
455
458
|
{!messagesCollapsed && filtered.length > 1 && !isSearching && (
|
|
456
459
|
<button
|
|
460
|
+
type="button"
|
|
457
461
|
onClick={() => setMessagesCollapsed(true)}
|
|
458
462
|
style={{
|
|
459
463
|
background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
|
|
@@ -501,6 +505,7 @@ const TicketConversation: React.FC = () => {
|
|
|
501
505
|
<span style={s.badge(C.emailBg, C.orange)}>Email</span>
|
|
502
506
|
) : (
|
|
503
507
|
<select
|
|
508
|
+
aria-label="Auteur du message"
|
|
504
509
|
value={msg.authorType}
|
|
505
510
|
onChange={(e) => handleToggleAuthor(msg.id, e.target.value === 'admin' ? 'client' : 'admin')}
|
|
506
511
|
disabled={togglingAuthor === msg.id}
|
|
@@ -537,6 +542,7 @@ const TicketConversation: React.FC = () => {
|
|
|
537
542
|
{/* Split button */}
|
|
538
543
|
{!msg.isInternal && !(msg as unknown as { deletedAt?: string }).deletedAt && (
|
|
539
544
|
<button
|
|
545
|
+
type="button"
|
|
540
546
|
onClick={() => handleSplitMessage(msg.id, ticketSubject)}
|
|
541
547
|
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', color: '#6b7280', padding: 0 }}
|
|
542
548
|
title="Extraire en nouveau ticket"
|
|
@@ -579,16 +585,17 @@ const TicketConversation: React.FC = () => {
|
|
|
579
585
|
{editingMsg === msg.id ? (
|
|
580
586
|
<div style={{ marginTop: '6px' }}>
|
|
581
587
|
<textarea
|
|
588
|
+
aria-label="Editer le message"
|
|
582
589
|
value={editBody}
|
|
583
590
|
onChange={(e) => { setEditBody(e.target.value); setEditHtml(e.target.value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br />')) }}
|
|
584
591
|
rows={4}
|
|
585
592
|
style={{ ...s.input, width: '100%', resize: 'vertical', fontSize: '13px' }}
|
|
586
593
|
/>
|
|
587
594
|
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
|
588
|
-
<button onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
|
|
595
|
+
<button type="button" onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
|
|
589
596
|
{savingEdit ? '...' : 'Enregistrer'}
|
|
590
597
|
</button>
|
|
591
|
-
<button onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
|
|
598
|
+
<button type="button" onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
|
|
592
599
|
Annuler
|
|
593
600
|
</button>
|
|
594
601
|
</div>
|
|
@@ -775,6 +782,7 @@ const TicketConversation: React.FC = () => {
|
|
|
775
782
|
))}
|
|
776
783
|
{features.ai && (
|
|
777
784
|
<button
|
|
785
|
+
type="button"
|
|
778
786
|
onClick={handleAiSuggestReply}
|
|
779
787
|
disabled={aiReplying || messages.length === 0}
|
|
780
788
|
style={{ ...s.outlineBtn('#17807c', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
|
|
@@ -800,7 +808,7 @@ const TicketConversation: React.FC = () => {
|
|
|
800
808
|
/>
|
|
801
809
|
{features.canned && cannedResponses.length > 0 && (
|
|
802
810
|
<>
|
|
803
|
-
<select onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
|
|
811
|
+
<select aria-label="Reponse rapide" onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
|
|
804
812
|
<option value="">Réponse rapide...</option>
|
|
805
813
|
{cannedResponses.map((cr) => (
|
|
806
814
|
<option key={cr.id} value={String(cr.id)}>{cr.title}</option>
|
|
@@ -818,6 +826,7 @@ const TicketConversation: React.FC = () => {
|
|
|
818
826
|
{/* Reply textarea (replacing RichTextEditor) */}
|
|
819
827
|
<div style={{ border: `1px solid ${C.border}`, borderRadius: '8px', overflow: 'hidden' }}>
|
|
820
828
|
<textarea
|
|
829
|
+
aria-label="Reponse au client"
|
|
821
830
|
value={replyBody}
|
|
822
831
|
onChange={(e) => {
|
|
823
832
|
const text = e.target.value
|
|
@@ -831,7 +840,6 @@ const TicketConversation: React.FC = () => {
|
|
|
831
840
|
minHeight: '120px',
|
|
832
841
|
padding: '12px',
|
|
833
842
|
border: 'none',
|
|
834
|
-
outline: 'none',
|
|
835
843
|
fontSize: '14px',
|
|
836
844
|
lineHeight: 1.5,
|
|
837
845
|
resize: 'vertical',
|
|
@@ -848,6 +856,7 @@ const TicketConversation: React.FC = () => {
|
|
|
848
856
|
ref={fileInputRef}
|
|
849
857
|
type="file"
|
|
850
858
|
multiple
|
|
859
|
+
aria-label="Joindre des fichiers"
|
|
851
860
|
onChange={handleReplyFileChange}
|
|
852
861
|
style={{ display: 'none' }}
|
|
853
862
|
accept="image/*,.pdf,.doc,.docx,.txt,.zip"
|
|
@@ -866,6 +875,7 @@ const TicketConversation: React.FC = () => {
|
|
|
866
875
|
{'\uD83D\uDCCE'} {file.name}
|
|
867
876
|
<button
|
|
868
877
|
type="button"
|
|
878
|
+
aria-label={`Retirer la piece jointe ${file.name}`}
|
|
869
879
|
onClick={() => setReplyFiles((prev) => prev.filter((_, idx) => idx !== i))}
|
|
870
880
|
style={{ border: 'none', background: 'none', color: '#ef4444', fontWeight: 700, cursor: 'pointer', fontSize: '14px', lineHeight: 1 }}
|
|
871
881
|
>
|
|
@@ -879,6 +889,7 @@ const TicketConversation: React.FC = () => {
|
|
|
879
889
|
{/* Send row */}
|
|
880
890
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '8px' }}>
|
|
881
891
|
<select
|
|
892
|
+
aria-label="Envoyer en tant que"
|
|
882
893
|
value={sendAsClient ? 'client' : 'admin'}
|
|
883
894
|
onChange={(e) => setSendAsClient(e.target.value === 'client')}
|
|
884
895
|
style={{ ...s.input, fontSize: '12px', padding: '6px 8px', fontWeight: 600 }}
|
|
@@ -896,11 +907,12 @@ const TicketConversation: React.FC = () => {
|
|
|
896
907
|
Envoyer au client
|
|
897
908
|
</label>
|
|
898
909
|
)}
|
|
899
|
-
<button data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
|
|
910
|
+
<button type="button" data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
|
|
900
911
|
{sending ? 'Envoi...' : isInternal ? 'Ajouter note' : notifyClient ? 'Envoyer + Notifier' : 'Sauvegarder'}
|
|
901
912
|
</button>
|
|
902
913
|
{!isInternal && (
|
|
903
914
|
<button
|
|
915
|
+
type="button"
|
|
904
916
|
onClick={() => setShowSchedule(!showSchedule)}
|
|
905
917
|
disabled={!replyBody.trim() && !replyHtml}
|
|
906
918
|
style={{ ...s.outlineBtn('#17807c', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
|
|
@@ -915,19 +927,21 @@ const TicketConversation: React.FC = () => {
|
|
|
915
927
|
<span style={{ fontSize: '12px', fontWeight: 600, color: '#17807c' }}>Programmer pour :</span>
|
|
916
928
|
<input
|
|
917
929
|
type="datetime-local"
|
|
930
|
+
aria-label="Date et heure d'envoi programme"
|
|
918
931
|
value={scheduleDate}
|
|
919
932
|
onChange={(e) => setScheduleDate(e.target.value)}
|
|
920
933
|
min={new Date().toISOString().slice(0, 16)}
|
|
921
934
|
style={{ ...s.input, fontSize: '12px', width: 'auto' }}
|
|
922
935
|
/>
|
|
923
936
|
<button
|
|
937
|
+
type="button"
|
|
924
938
|
onClick={handleScheduleReply}
|
|
925
939
|
disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
|
|
926
940
|
style={{ ...s.btn('#17807c', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
|
|
927
941
|
>
|
|
928
942
|
{sending ? '...' : '\u23F0 Programmer'}
|
|
929
943
|
</button>
|
|
930
|
-
<button onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
|
|
944
|
+
<button type="button" aria-label="Annuler la programmation" onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
|
|
931
945
|
</div>
|
|
932
946
|
)}
|
|
933
947
|
<div style={{ fontSize: '11px', color: C.textMuted, marginTop: '4px', textAlign: 'right' }}>
|
|
@@ -1088,4 +1102,24 @@ const TicketConversation: React.FC = () => {
|
|
|
1088
1102
|
|
|
1089
1103
|
|
|
1090
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* Payload mounts this component straight from the import map as the `ui` field
|
|
1107
|
+
* of the Tickets edit view, so the plugin never gets to render an ancestor for
|
|
1108
|
+
* it: the boundary has to live inside the module itself. Without it a single
|
|
1109
|
+
* render error in any of the twelve sub-components blanks the whole ticket
|
|
1110
|
+
* screen.
|
|
1111
|
+
*
|
|
1112
|
+
* The document id is used as a reset key so that navigating from a ticket that
|
|
1113
|
+
* broke to another one clears the error on its own.
|
|
1114
|
+
*/
|
|
1115
|
+
const TicketConversation: React.FC = () => {
|
|
1116
|
+
const { id } = useDocumentIdFromUrl()
|
|
1117
|
+
|
|
1118
|
+
return (
|
|
1119
|
+
<AdminErrorBoundary viewName="TicketConversation" resetKeys={[id]}>
|
|
1120
|
+
<TicketConversationInner />
|
|
1121
|
+
</AdminErrorBoundary>
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1091
1125
|
export default TicketConversation
|
|
@@ -1,13 +1,37 @@
|
|
|
1
1
|
import type { Endpoint } from 'payload'
|
|
2
|
+
import { commitTransaction, initTransaction, killTransaction } from 'payload'
|
|
2
3
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
4
|
+
import { FIXED_SLUGS } from '../utils/slugs'
|
|
3
5
|
import { requireClient, handleAuthError } from '../utils/auth'
|
|
4
6
|
import { dbFind, dbDelete } from '../utils/db'
|
|
5
7
|
|
|
8
|
+
interface AttachmentRow {
|
|
9
|
+
file?: number | string | { id?: number | string } | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Collect the media ids referenced by an `attachments` array field. */
|
|
13
|
+
function collectAttachmentIds(
|
|
14
|
+
docs: Array<{ attachments?: AttachmentRow[] | null }>,
|
|
15
|
+
into: Set<number | string>,
|
|
16
|
+
): void {
|
|
17
|
+
for (const doc of docs) {
|
|
18
|
+
for (const attachment of doc.attachments ?? []) {
|
|
19
|
+
const file = attachment?.file
|
|
20
|
+
const id = file != null && typeof file === 'object' ? file.id : file
|
|
21
|
+
if (id != null) into.add(id)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
/**
|
|
7
27
|
* POST /api/support/delete-account
|
|
8
28
|
* RGPD — Right to erasure (Article 17).
|
|
9
|
-
*
|
|
10
|
-
*
|
|
29
|
+
*
|
|
30
|
+
* Erases every row the plugin holds about the caller, and the uploaded files
|
|
31
|
+
* those rows point at. The whole sequence runs inside a single Payload
|
|
32
|
+
* transaction: a failure halfway through must not leave a half-deleted
|
|
33
|
+
* account, which would be worse than not deleting at all — the person would
|
|
34
|
+
* believe their data is gone while some of it is still readable.
|
|
11
35
|
*/
|
|
12
36
|
export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
37
|
return {
|
|
@@ -29,7 +53,8 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
29
53
|
)
|
|
30
54
|
}
|
|
31
55
|
|
|
32
|
-
// Verify password
|
|
56
|
+
// Verify password. Deliberately outside the transaction: it is a read,
|
|
57
|
+
// and `payload.login` writes nothing we would want to roll back.
|
|
33
58
|
try {
|
|
34
59
|
await payload.login({
|
|
35
60
|
collection: slugs.supportClients as any,
|
|
@@ -43,59 +68,178 @@ export function createDeleteAccountEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
const clientId = req.user.id
|
|
71
|
+
const clientEmail = req.user.email
|
|
46
72
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const ticketIds = tickets.docs.map((t) => t.id)
|
|
57
|
-
|
|
58
|
-
// 2. Batch delete related data using where clauses
|
|
59
|
-
if (ticketIds.length > 0) {
|
|
60
|
-
await dbDelete(payload, slugs.ticketMessages, {
|
|
61
|
-
where: { ticket: { in: ticketIds } },
|
|
62
|
-
overrideAccess: true,
|
|
63
|
-
})
|
|
73
|
+
// Several of the collections below only exist when the matching
|
|
74
|
+
// feature flag is on (chat, pendingEmails, ai, timeTracking,
|
|
75
|
+
// authLogs, emailTracking). Addressing one that was never registered
|
|
76
|
+
// throws, and inside a transaction that would roll the whole erasure
|
|
77
|
+
// back — so an install with chat disabled could never delete an
|
|
78
|
+
// account at all.
|
|
79
|
+
const isRegistered = (slug: string): boolean =>
|
|
80
|
+
Boolean((payload.collections as Record<string, unknown> | undefined)?.[slug])
|
|
64
81
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
82
|
+
// No-op on adapters that do not expose `beginTransaction` — the SQLite
|
|
83
|
+
// adapter only does when the host passes `transactionOptions`. The
|
|
84
|
+
// sequence then runs unwrapped, exactly as it did before.
|
|
85
|
+
const startedTransaction = await initTransaction(req)
|
|
69
86
|
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
try {
|
|
88
|
+
// 1. Tickets owned by the client.
|
|
89
|
+
const tickets = await dbFind(payload, slugs.tickets, {
|
|
90
|
+
where: { client: { equals: clientId } },
|
|
91
|
+
limit: 10000,
|
|
92
|
+
depth: 0,
|
|
72
93
|
overrideAccess: true,
|
|
94
|
+
select: { id: true },
|
|
95
|
+
req,
|
|
73
96
|
})
|
|
74
97
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
const ticketIds = tickets.docs.map((t) => t.id)
|
|
99
|
+
|
|
100
|
+
// 2. Uploaded files. They live in the HOST's media collection, so
|
|
101
|
+
// their ids have to be gathered before the rows that reference them
|
|
102
|
+
// are gone.
|
|
103
|
+
const attachmentIds = new Set<number | string>()
|
|
104
|
+
|
|
105
|
+
if (ticketIds.length > 0) {
|
|
106
|
+
const messages = await dbFind<{ attachments?: AttachmentRow[] | null }>(
|
|
107
|
+
payload,
|
|
108
|
+
slugs.ticketMessages,
|
|
109
|
+
{
|
|
110
|
+
where: { ticket: { in: ticketIds } },
|
|
111
|
+
limit: 10000,
|
|
112
|
+
depth: 0,
|
|
113
|
+
overrideAccess: true,
|
|
114
|
+
req,
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
collectAttachmentIds(messages.docs, attachmentIds)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isRegistered(slugs.pendingEmails)) {
|
|
121
|
+
const pendingEmails = await dbFind<{ attachments?: AttachmentRow[] | null }>(
|
|
122
|
+
payload,
|
|
123
|
+
slugs.pendingEmails,
|
|
124
|
+
{
|
|
125
|
+
where: { client: { equals: clientId } },
|
|
126
|
+
limit: 10000,
|
|
127
|
+
depth: 0,
|
|
128
|
+
overrideAccess: true,
|
|
129
|
+
req,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
collectAttachmentIds(pendingEmails.docs, attachmentIds)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. Rows that point at one of those tickets. They must go BEFORE
|
|
136
|
+
// the tickets themselves: their `ticket` relationship is required,
|
|
137
|
+
// so the FK would fail on the ticket delete — and Payload's
|
|
138
|
+
// delete-many collects per-document errors instead of throwing, so
|
|
139
|
+
// the failure would be silent and the account would survive.
|
|
140
|
+
if (ticketIds.length > 0) {
|
|
141
|
+
for (const slug of [FIXED_SLUGS.ticketFeedback, FIXED_SLUGS.ticketCollaborators]) {
|
|
142
|
+
if (!isRegistered(slug)) continue
|
|
143
|
+
await dbDelete(payload, slug, {
|
|
144
|
+
where: {
|
|
145
|
+
or: [
|
|
146
|
+
{ ticket: { in: ticketIds } },
|
|
147
|
+
{ client: { equals: clientId } },
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
overrideAccess: true,
|
|
151
|
+
req,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 4. The tickets. Their own beforeDelete cascade takes the messages,
|
|
157
|
+
// the activity log, the time entries and the surveys with them.
|
|
158
|
+
if (ticketIds.length > 0) {
|
|
159
|
+
await dbDelete(payload, slugs.tickets, {
|
|
160
|
+
where: { client: { equals: clientId } },
|
|
161
|
+
overrideAccess: true,
|
|
162
|
+
req,
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// 5. Rows keyed on the client id, whatever ticket they belong to.
|
|
167
|
+
for (const slug of [
|
|
168
|
+
slugs.satisfactionSurveys,
|
|
169
|
+
slugs.chatMessages,
|
|
170
|
+
slugs.notificationQueue,
|
|
171
|
+
slugs.pendingEmails,
|
|
172
|
+
FIXED_SLUGS.clientSummaries,
|
|
173
|
+
FIXED_SLUGS.ticketFeedback,
|
|
174
|
+
]) {
|
|
175
|
+
if (!isRegistered(slug)) continue
|
|
176
|
+
await dbDelete(payload, slug, {
|
|
177
|
+
where: { client: { equals: clientId } },
|
|
178
|
+
overrideAccess: true,
|
|
179
|
+
req,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Collaborator invitations can predate the account they point at, so
|
|
184
|
+
// they are also matched on the address alone.
|
|
185
|
+
if (isRegistered(FIXED_SLUGS.ticketCollaborators)) {
|
|
186
|
+
await dbDelete(payload, FIXED_SLUGS.ticketCollaborators, {
|
|
187
|
+
where: {
|
|
188
|
+
or: [
|
|
189
|
+
{ client: { equals: clientId } },
|
|
190
|
+
...(clientEmail ? [{ email: { equals: clientEmail } }] : []),
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
overrideAccess: true,
|
|
194
|
+
req,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 6. Journals, keyed on the address only.
|
|
199
|
+
if (clientEmail) {
|
|
200
|
+
if (isRegistered(slugs.authLogs)) {
|
|
201
|
+
await dbDelete(payload, slugs.authLogs, {
|
|
202
|
+
where: { email: { equals: clientEmail } },
|
|
203
|
+
overrideAccess: true,
|
|
204
|
+
req,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (isRegistered(slugs.emailLogs)) {
|
|
209
|
+
await dbDelete(payload, slugs.emailLogs, {
|
|
210
|
+
where: {
|
|
211
|
+
or: [
|
|
212
|
+
{ senderEmail: { equals: clientEmail } },
|
|
213
|
+
{ recipientEmail: { equals: clientEmail } },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
overrideAccess: true,
|
|
217
|
+
req,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 7. The uploaded files themselves.
|
|
223
|
+
for (const id of isRegistered(slugs.media) ? attachmentIds : []) {
|
|
224
|
+
await dbDelete(payload, slugs.media, {
|
|
225
|
+
id,
|
|
226
|
+
overrideAccess: true,
|
|
227
|
+
req,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 8. The account.
|
|
232
|
+
await dbDelete(payload, slugs.supportClients, {
|
|
233
|
+
id: clientId,
|
|
78
234
|
overrideAccess: true,
|
|
235
|
+
req,
|
|
79
236
|
})
|
|
80
|
-
}
|
|
81
237
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// 4. Batch delete chat messages
|
|
89
|
-
await dbDelete(payload, slugs.chatMessages, {
|
|
90
|
-
where: { client: { equals: clientId } },
|
|
91
|
-
overrideAccess: true,
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
// 5. Delete the client account
|
|
95
|
-
await dbDelete(payload, slugs.supportClients, {
|
|
96
|
-
id: clientId,
|
|
97
|
-
overrideAccess: true,
|
|
98
|
-
})
|
|
238
|
+
if (startedTransaction) await commitTransaction(req)
|
|
239
|
+
} catch (err) {
|
|
240
|
+
if (startedTransaction) await killTransaction(req)
|
|
241
|
+
throw err
|
|
242
|
+
}
|
|
99
243
|
|
|
100
244
|
const headers = new Headers({ 'Content-Type': 'application/json' })
|
|
101
245
|
const secure = process.env.NODE_ENV === 'production'
|