@badgie/crm-cli 0.6.0 → 0.7.0
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/AGENTS.md +58 -4
- package/CHANGELOG.md +49 -0
- package/README.md +126 -17
- package/dist/commands/docs.js +3 -1
- package/dist/commands/docs.js.map +1 -1
- package/dist/commands/finance.js +734 -0
- package/dist/commands/finance.js.map +1 -1
- package/dist/commands/query.js +3 -5
- package/dist/commands/query.js.map +1 -1
- package/dist/commands/top-clients.js +820 -23
- package/dist/commands/top-clients.js.map +1 -1
- package/dist/commands/welcome.js +9 -7
- package/dist/commands/welcome.js.map +1 -1
- package/dist/core/env.js +1 -1
- package/dist/core/env.js.map +1 -1
- package/dist/core/errors.js +2 -2
- package/dist/core/errors.js.map +1 -1
- package/dist/core/supabase.js +2 -2
- package/dist/core/supabase.js.map +1 -1
- package/package.json +1 -1
- package/dist/commands/leads-list.js +0 -25
- package/dist/commands/leads-list.js.map +0 -1
- package/dist/core/services/leads.js +0 -41
- package/dist/core/services/leads.js.map +0 -1
|
@@ -1,32 +1,293 @@
|
|
|
1
1
|
import { getAuthedClient } from '../core/supabase.js';
|
|
2
2
|
import { commonListOptions, output, parseColumns, parseLimit } from '../core/format.js';
|
|
3
|
-
import { maybeResolve } from '../core/resolve.js';
|
|
3
|
+
import { maybeResolve, resolveRef } from '../core/resolve.js';
|
|
4
4
|
import { bulkOptions, collectIds, runBulk } from '../core/bulk.js';
|
|
5
5
|
import { swapIfLegacyOrder } from '../core/compat.js';
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const ACTIVE_CLIENT_STATUSES = ['active', 'active free trial'];
|
|
7
|
+
const REVIEW_SELECT = `
|
|
8
|
+
*,
|
|
9
|
+
reviewed_by_member:team_members!client_monthly_reviews_reviewed_by_fkey(id, full_name, email, avatar_url),
|
|
10
|
+
client:clients(
|
|
11
|
+
id, name, email, sport, city, status, assigned_to,
|
|
12
|
+
plan_type, plan_student_tier, plan_student_count, plan_payment_frequency, plan_payment_amount,
|
|
13
|
+
assigned:team_members!clients_assigned_to_fkey(id, full_name, email, avatar_url)
|
|
14
|
+
)
|
|
15
|
+
`;
|
|
16
|
+
const CLIENT_CONFIG_SELECT = `
|
|
17
|
+
id, name, email, sport, city, status, assigned_to,
|
|
18
|
+
plan_type, plan_student_tier, plan_student_count, plan_payment_frequency, plan_payment_amount,
|
|
19
|
+
assigned:team_members!clients_assigned_to_fkey(id, full_name, email, avatar_url)
|
|
20
|
+
`;
|
|
21
|
+
const TOP_CLIENT_QUESTIONS = [
|
|
22
|
+
{
|
|
23
|
+
id: 'notes',
|
|
24
|
+
key: 'notes',
|
|
25
|
+
label: 'Notas generales',
|
|
26
|
+
storage: { table: 'client_monthly_reviews', column: 'notes', type: 'text' },
|
|
27
|
+
answerCommand: 'badgie-crm top-clients notes set --review-id <id> --notes "..."',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'needs',
|
|
31
|
+
key: 'needs',
|
|
32
|
+
label: 'Necesidades identificadas',
|
|
33
|
+
storage: { table: 'client_monthly_reviews', column: 'needs', type: 'text[]' },
|
|
34
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question needs --value \'["..."]\'',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'testimonials',
|
|
38
|
+
key: 'testimonials',
|
|
39
|
+
label: 'Testimonios',
|
|
40
|
+
storage: { table: 'client_monthly_reviews', column: 'testimonials', type: 'text[]' },
|
|
41
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question testimonials --value \'["..."]\'',
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'risk_level',
|
|
45
|
+
key: 'risk_level',
|
|
46
|
+
label: 'Riesgo',
|
|
47
|
+
allowedValues: ['low', 'medium', 'high'],
|
|
48
|
+
storage: { table: 'client_monthly_reviews', column: 'risk_level', type: 'text' },
|
|
49
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question risk_level --value high',
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'next_actions',
|
|
53
|
+
key: 'next_actions',
|
|
54
|
+
label: 'Proximas acciones',
|
|
55
|
+
storage: { table: 'client_monthly_reviews', column: 'next_actions', type: 'text[]' },
|
|
56
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question next_actions --value \'["..."]\'',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'status',
|
|
60
|
+
key: 'status',
|
|
61
|
+
label: 'Estado',
|
|
62
|
+
allowedValues: ['pending', 'in_progress', 'completed'],
|
|
63
|
+
storage: { table: 'client_monthly_reviews', column: 'status', type: 'text' },
|
|
64
|
+
answerCommand: 'badgie-crm top-clients set-status completed <id>',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'reviewed_at',
|
|
68
|
+
key: 'reviewed_at',
|
|
69
|
+
label: 'Fecha de revision',
|
|
70
|
+
storage: { table: 'client_monthly_reviews', column: 'reviewed_at', type: 'timestamp' },
|
|
71
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question reviewed_at --value 2026-04-26',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'satisfaction_score',
|
|
75
|
+
key: 'satisfaction_score',
|
|
76
|
+
label: 'Satisfaccion',
|
|
77
|
+
allowedValues: [1, 2, 3, 4, 5],
|
|
78
|
+
storage: { table: 'client_monthly_reviews', column: 'satisfaction_score', type: 'integer' },
|
|
79
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question satisfaction_score --value 5',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'contacted',
|
|
83
|
+
key: 'contacted',
|
|
84
|
+
label: 'Contactado esta semana',
|
|
85
|
+
allowedValues: [true, false],
|
|
86
|
+
storage: { table: 'client_monthly_reviews', column: 'metadata.contacted/contact_log', type: 'jsonb' },
|
|
87
|
+
answerCommand: 'badgie-crm top-clients answer set --review-id <id> --question contacted --value true',
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
const QUESTION_ALIASES = Object.fromEntries(TOP_CLIENT_QUESTIONS.flatMap((q) => [
|
|
91
|
+
[q.id, q.key],
|
|
92
|
+
[q.key, q.key],
|
|
93
|
+
]));
|
|
94
|
+
function currentMonthYear(opts) {
|
|
8
95
|
const now = new Date();
|
|
9
96
|
const month = typeof opts.month === 'string' ? Number(opts.month) : now.getMonth() + 1;
|
|
10
97
|
const year = typeof opts.year === 'string' ? Number(opts.year) : now.getFullYear();
|
|
11
|
-
|
|
98
|
+
assertMonthYear(month, year);
|
|
99
|
+
return { month, year };
|
|
100
|
+
}
|
|
101
|
+
function assertMonthYear(month, year) {
|
|
102
|
+
if (!Number.isInteger(month) || month < 1 || month > 12) {
|
|
103
|
+
throw new Error('--month must be an integer between 1 and 12');
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
|
|
106
|
+
throw new Error('--year must be a YYYY integer');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function boolFromUnknown(value, label) {
|
|
110
|
+
if (typeof value === 'boolean')
|
|
111
|
+
return value;
|
|
112
|
+
if (typeof value !== 'string')
|
|
113
|
+
throw new Error(`${label} must be true|false`);
|
|
114
|
+
const v = value.trim().toLowerCase();
|
|
115
|
+
if (v === 'true' || v === '1' || v === 'yes')
|
|
116
|
+
return true;
|
|
117
|
+
if (v === 'false' || v === '0' || v === 'no')
|
|
118
|
+
return false;
|
|
119
|
+
throw new Error(`${label} must be true|false (got "${value}")`);
|
|
120
|
+
}
|
|
121
|
+
function nullableString(value) {
|
|
122
|
+
if (value === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
if (typeof value !== 'string')
|
|
125
|
+
return undefined;
|
|
126
|
+
const v = value.trim();
|
|
127
|
+
if (v === '' || v.toLowerCase() === 'null' || v.toLowerCase() === 'none')
|
|
128
|
+
return null;
|
|
129
|
+
return v;
|
|
130
|
+
}
|
|
131
|
+
function numberOrNull(value, label) {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
if (typeof value !== 'string' || value.trim() === '' || value.trim().toLowerCase() === 'null') {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const n = Number(value);
|
|
138
|
+
if (!Number.isFinite(n))
|
|
139
|
+
throw new Error(`${label} must be a number`);
|
|
140
|
+
return n;
|
|
141
|
+
}
|
|
142
|
+
function parseJsonish(value) {
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (trimmed === '')
|
|
145
|
+
return '';
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(trimmed);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function parseStringArray(value, label) {
|
|
154
|
+
const parsed = parseJsonish(value);
|
|
155
|
+
if (parsed === null)
|
|
156
|
+
return null;
|
|
157
|
+
if (Array.isArray(parsed)) {
|
|
158
|
+
return parsed.map((v) => String(v).trim()).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
return value
|
|
161
|
+
.split(/\n|,/)
|
|
162
|
+
.map((v) => v.trim())
|
|
163
|
+
.filter(Boolean);
|
|
164
|
+
}
|
|
165
|
+
function normalizeStatus(status) {
|
|
166
|
+
if (status === 'pending' || status === 'in_progress' || status === 'completed')
|
|
167
|
+
return status;
|
|
168
|
+
throw new Error('status must be pending|in_progress|completed');
|
|
169
|
+
}
|
|
170
|
+
function normalizeRisk(risk) {
|
|
171
|
+
if (risk === 'low' || risk === 'medium' || risk === 'high')
|
|
172
|
+
return risk;
|
|
173
|
+
throw new Error('risk_level must be low|medium|high');
|
|
174
|
+
}
|
|
175
|
+
function safeMetadata(metadata) {
|
|
176
|
+
if (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) {
|
|
177
|
+
return { ...metadata };
|
|
178
|
+
}
|
|
179
|
+
return {};
|
|
180
|
+
}
|
|
181
|
+
function isValidContactLogEntry(x) {
|
|
182
|
+
if (!x || typeof x !== 'object')
|
|
183
|
+
return false;
|
|
184
|
+
const o = x;
|
|
185
|
+
return typeof o.at === 'string' && (o.type === 'marked' || o.type === 'unmarked');
|
|
186
|
+
}
|
|
187
|
+
function mergeContactedMetadata(existing, contacted, reviewStatus) {
|
|
188
|
+
const base = safeMetadata(existing);
|
|
189
|
+
const prevLog = Array.isArray(base.contact_log)
|
|
190
|
+
? base.contact_log.filter(isValidContactLogEntry)
|
|
191
|
+
: [];
|
|
192
|
+
return {
|
|
193
|
+
...base,
|
|
194
|
+
contacted,
|
|
195
|
+
contact_log: [
|
|
196
|
+
...prevLog,
|
|
197
|
+
{
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
type: contacted ? 'marked' : 'unmarked',
|
|
200
|
+
review_status: reviewStatus || 'pending',
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function isContactedThisWeek(review) {
|
|
206
|
+
const now = new Date();
|
|
207
|
+
const dow = now.getDay();
|
|
208
|
+
const offsetToMonday = dow === 0 ? -6 : 1 - dow;
|
|
209
|
+
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetToMonday);
|
|
210
|
+
monday.setHours(0, 0, 0, 0);
|
|
211
|
+
const sunday = new Date(monday);
|
|
212
|
+
sunday.setDate(monday.getDate() + 6);
|
|
213
|
+
sunday.setHours(23, 59, 59, 999);
|
|
214
|
+
const metadata = safeMetadata(review.metadata);
|
|
215
|
+
const log = Array.isArray(metadata.contact_log)
|
|
216
|
+
? metadata.contact_log.filter(isValidContactLogEntry)
|
|
217
|
+
: metadata.contacted === true && (review.updated_at || review.created_at)
|
|
218
|
+
? [{ at: review.updated_at || review.created_at || '', type: 'marked' }]
|
|
219
|
+
: [];
|
|
220
|
+
const inWeek = log.filter((e) => {
|
|
221
|
+
const t = new Date(e.at).getTime();
|
|
222
|
+
return t >= monday.getTime() && t <= sunday.getTime();
|
|
223
|
+
});
|
|
224
|
+
inWeek.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime());
|
|
225
|
+
return inWeek[0]?.type === 'marked';
|
|
226
|
+
}
|
|
227
|
+
function answered(value) {
|
|
228
|
+
if (value === null || value === undefined)
|
|
229
|
+
return false;
|
|
230
|
+
if (Array.isArray(value))
|
|
231
|
+
return value.length > 0;
|
|
232
|
+
if (typeof value === 'string')
|
|
233
|
+
return value.trim().length > 0;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
function outputOptions(opts) {
|
|
237
|
+
return { pretty: !!opts.pretty, columns: parseColumns(opts) };
|
|
238
|
+
}
|
|
239
|
+
async function getCurrentTeamMemberId(client) {
|
|
240
|
+
const { data } = await client.auth.getUser();
|
|
241
|
+
const email = data.user?.email;
|
|
242
|
+
if (!email)
|
|
243
|
+
return null;
|
|
244
|
+
const { data: member, error } = await client
|
|
245
|
+
.from('team_members')
|
|
246
|
+
.select('id')
|
|
247
|
+
.eq('email', email)
|
|
248
|
+
.maybeSingle();
|
|
249
|
+
if (error)
|
|
250
|
+
throw error;
|
|
251
|
+
return member?.id ?? null;
|
|
252
|
+
}
|
|
253
|
+
async function fetchReview(client, id) {
|
|
254
|
+
const { data, error } = await client
|
|
255
|
+
.from('client_monthly_reviews')
|
|
256
|
+
.select(REVIEW_SELECT)
|
|
257
|
+
.eq('id', id)
|
|
258
|
+
.single();
|
|
259
|
+
if (error)
|
|
260
|
+
throw error;
|
|
261
|
+
return data;
|
|
262
|
+
}
|
|
263
|
+
async function listTopClients(_a, opts) {
|
|
264
|
+
const { client } = await getAuthedClient();
|
|
265
|
+
const { month, year } = currentMonthYear(opts);
|
|
266
|
+
const kam = await maybeResolve(client, 'team_member', opts.kam);
|
|
267
|
+
const reviewer = await maybeResolve(client, 'team_member', opts.reviewer);
|
|
268
|
+
const limit = parseLimit(opts);
|
|
269
|
+
const hasClientSideFilters = !!kam || typeof opts.search === 'string';
|
|
12
270
|
let reviewsQ = client
|
|
13
271
|
.from('client_monthly_reviews')
|
|
14
|
-
.select(
|
|
15
|
-
reviewed_by_member:team_members!client_monthly_reviews_reviewed_by_fkey(id, full_name, email),
|
|
16
|
-
client:clients(id, name, sport, city, assigned_to, assigned:team_members!clients_assigned_to_fkey(full_name, email))`)
|
|
272
|
+
.select(REVIEW_SELECT)
|
|
17
273
|
.eq('month', month)
|
|
18
274
|
.eq('year', year)
|
|
19
275
|
.order('created_at', { ascending: false })
|
|
20
|
-
.limit(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
reviewsQ = reviewsQ.eq('reviewed_by', kam);
|
|
276
|
+
.limit(hasClientSideFilters ? Math.max(1000, limit) : limit);
|
|
277
|
+
if (reviewer)
|
|
278
|
+
reviewsQ = reviewsQ.eq('reviewed_by', reviewer);
|
|
24
279
|
if (typeof opts.status === 'string')
|
|
25
280
|
reviewsQ = reviewsQ.eq('status', opts.status);
|
|
26
281
|
const { data, error } = await reviewsQ;
|
|
27
282
|
if (error)
|
|
28
283
|
throw error;
|
|
29
|
-
let rows = data ?? [];
|
|
284
|
+
let rows = (data ?? []);
|
|
285
|
+
if (kam) {
|
|
286
|
+
rows = rows.filter((r) => {
|
|
287
|
+
const c = r.client ?? null;
|
|
288
|
+
return c?.assigned_to === kam || r.reviewed_by === kam;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
30
291
|
if (typeof opts.search === 'string') {
|
|
31
292
|
const needle = opts.search.toLowerCase();
|
|
32
293
|
rows = rows.filter((r) => {
|
|
@@ -34,13 +295,168 @@ async function listTopClients(_a, opts) {
|
|
|
34
295
|
return c?.name?.toLowerCase().includes(needle) ?? false;
|
|
35
296
|
});
|
|
36
297
|
}
|
|
37
|
-
|
|
298
|
+
if (hasClientSideFilters)
|
|
299
|
+
rows = rows.slice(0, limit);
|
|
300
|
+
output(rows, outputOptions(opts));
|
|
301
|
+
}
|
|
302
|
+
async function getTopClient(args, opts) {
|
|
303
|
+
const id = args.id;
|
|
304
|
+
if (!id)
|
|
305
|
+
throw new Error('review id is required');
|
|
306
|
+
const { client } = await getAuthedClient();
|
|
307
|
+
output(await fetchReview(client, id), { pretty: !!opts.pretty });
|
|
308
|
+
}
|
|
309
|
+
async function listQuestions(_a, opts) {
|
|
310
|
+
output({
|
|
311
|
+
source: 'client_monthly_reviews columns used by the current Top Clients UI',
|
|
312
|
+
questions: TOP_CLIENT_QUESTIONS,
|
|
313
|
+
}, { pretty: !!opts.pretty });
|
|
314
|
+
}
|
|
315
|
+
async function listAnswers(_a, opts) {
|
|
316
|
+
const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
|
|
317
|
+
if (!reviewId)
|
|
318
|
+
throw new Error('--review-id is required');
|
|
319
|
+
const { client } = await getAuthedClient();
|
|
320
|
+
const review = await fetchReview(client, reviewId);
|
|
321
|
+
const rows = TOP_CLIENT_QUESTIONS.map((q) => {
|
|
322
|
+
let value = review[q.key];
|
|
323
|
+
if (q.key === 'contacted')
|
|
324
|
+
value = isContactedThisWeek(review);
|
|
325
|
+
return {
|
|
326
|
+
id: q.id,
|
|
327
|
+
key: q.key,
|
|
328
|
+
label: q.label,
|
|
329
|
+
value,
|
|
330
|
+
answered: answered(value),
|
|
331
|
+
storage: q.storage,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
output(rows, outputOptions(opts));
|
|
335
|
+
}
|
|
336
|
+
async function summary(_a, opts) {
|
|
337
|
+
const { client } = await getAuthedClient();
|
|
338
|
+
const { month, year } = currentMonthYear(opts);
|
|
339
|
+
const [reviewsRes, clientsRes] = await Promise.all([
|
|
340
|
+
client
|
|
341
|
+
.from('client_monthly_reviews')
|
|
342
|
+
.select('id, client_id, status, risk_level, reviewed_by, reviewed_at, metadata, created_at, updated_at')
|
|
343
|
+
.eq('month', month)
|
|
344
|
+
.eq('year', year),
|
|
345
|
+
client
|
|
346
|
+
.from('clients')
|
|
347
|
+
.select('id, name, status, assigned_to')
|
|
348
|
+
.in('status', ACTIVE_CLIENT_STATUSES),
|
|
349
|
+
]);
|
|
350
|
+
if (reviewsRes.error)
|
|
351
|
+
throw reviewsRes.error;
|
|
352
|
+
if (clientsRes.error)
|
|
353
|
+
throw clientsRes.error;
|
|
354
|
+
const reviews = reviewsRes.data ?? [];
|
|
355
|
+
const activeClients = clientsRes.data ?? [];
|
|
356
|
+
const totalClientIds = new Set();
|
|
357
|
+
activeClients.forEach((c) => totalClientIds.add(c.id));
|
|
358
|
+
reviews.forEach((r) => {
|
|
359
|
+
if (r.client_id)
|
|
360
|
+
totalClientIds.add(r.client_id);
|
|
361
|
+
});
|
|
362
|
+
const completed = reviews.filter((r) => r.status === 'completed').length;
|
|
363
|
+
const inProgress = reviews.filter((r) => r.status === 'in_progress').length;
|
|
364
|
+
const pending = [...totalClientIds].filter((clientId) => {
|
|
365
|
+
const r = reviews.find((x) => x.client_id === clientId);
|
|
366
|
+
return !r || (r.status !== 'completed' && r.status !== 'in_progress');
|
|
367
|
+
}).length;
|
|
368
|
+
const contacted = reviews.filter(isContactedThisWeek).length;
|
|
369
|
+
const total = totalClientIds.size;
|
|
370
|
+
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
371
|
+
const byRisk = {
|
|
372
|
+
low: reviews.filter((r) => r.risk_level === 'low').length,
|
|
373
|
+
medium: reviews.filter((r) => r.risk_level === 'medium').length,
|
|
374
|
+
high: reviews.filter((r) => r.risk_level === 'high').length,
|
|
375
|
+
unset: reviews.filter((r) => !r.risk_level).length,
|
|
376
|
+
};
|
|
377
|
+
output({
|
|
378
|
+
month,
|
|
379
|
+
year,
|
|
380
|
+
total_clients_in_scope: total,
|
|
381
|
+
active_clients: activeClients.length,
|
|
382
|
+
reviews_created: reviews.length,
|
|
383
|
+
completed,
|
|
384
|
+
in_progress: inProgress,
|
|
385
|
+
pending,
|
|
386
|
+
contacted_this_week: contacted,
|
|
387
|
+
completion_rate: completionRate,
|
|
388
|
+
by_risk: byRisk,
|
|
389
|
+
}, { pretty: !!opts.pretty });
|
|
390
|
+
}
|
|
391
|
+
async function generate(_a, opts) {
|
|
392
|
+
const { client } = await getAuthedClient();
|
|
393
|
+
const { month, year } = currentMonthYear(opts);
|
|
394
|
+
const dryRun = !!opts.dryRun;
|
|
395
|
+
const { data: clients, error: clientsError } = await client
|
|
396
|
+
.from('clients')
|
|
397
|
+
.select('id, name, status, assigned_to')
|
|
398
|
+
.in('status', ACTIVE_CLIENT_STATUSES)
|
|
399
|
+
.order('name');
|
|
400
|
+
if (clientsError)
|
|
401
|
+
throw clientsError;
|
|
402
|
+
const clientIds = (clients ?? []).map((c) => c.id);
|
|
403
|
+
let existing = [];
|
|
404
|
+
if (clientIds.length > 0) {
|
|
405
|
+
const { data, error } = await client
|
|
406
|
+
.from('client_monthly_reviews')
|
|
407
|
+
.select('id, client_id')
|
|
408
|
+
.eq('month', month)
|
|
409
|
+
.eq('year', year)
|
|
410
|
+
.in('client_id', clientIds);
|
|
411
|
+
if (error)
|
|
412
|
+
throw error;
|
|
413
|
+
existing = data ?? [];
|
|
414
|
+
}
|
|
415
|
+
const existingByClient = new Map(existing.map((r) => [r.client_id, r.id]));
|
|
416
|
+
const toCreate = (clients ?? []).filter((c) => !existingByClient.has(c.id));
|
|
417
|
+
const rows = toCreate.map((c) => ({
|
|
418
|
+
client_id: c.id,
|
|
419
|
+
month,
|
|
420
|
+
year,
|
|
421
|
+
status: 'pending',
|
|
422
|
+
reviewed_by: c.assigned_to ?? null,
|
|
423
|
+
}));
|
|
424
|
+
if (dryRun || rows.length === 0) {
|
|
425
|
+
output({
|
|
426
|
+
dry_run: dryRun,
|
|
427
|
+
month,
|
|
428
|
+
year,
|
|
429
|
+
active_clients: clients?.length ?? 0,
|
|
430
|
+
existing_reviews: existing.length,
|
|
431
|
+
would_create: rows.length,
|
|
432
|
+
create_preview: toCreate.slice(0, parseLimit(opts, 20)).map((c) => ({
|
|
433
|
+
client_id: c.id,
|
|
434
|
+
name: c.name,
|
|
435
|
+
reviewed_by: c.assigned_to,
|
|
436
|
+
})),
|
|
437
|
+
}, { pretty: !!opts.pretty });
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const { data, error } = await client
|
|
441
|
+
.from('client_monthly_reviews')
|
|
442
|
+
.insert(rows)
|
|
443
|
+
.select(REVIEW_SELECT);
|
|
444
|
+
if (error)
|
|
445
|
+
throw error;
|
|
446
|
+
output({
|
|
447
|
+
month,
|
|
448
|
+
year,
|
|
449
|
+
created: data?.length ?? 0,
|
|
450
|
+
existing_reviews: existing.length,
|
|
451
|
+
reviews: data ?? [],
|
|
452
|
+
}, { pretty: !!opts.pretty });
|
|
38
453
|
}
|
|
39
454
|
async function setReviewStatus(args, opts) {
|
|
40
455
|
swapIfLegacyOrder(args, 'status', 'id', 'top-clients set-status');
|
|
41
456
|
const { client } = await getAuthedClient();
|
|
42
457
|
if (!args.status)
|
|
43
458
|
throw new Error('status argument is required');
|
|
459
|
+
const status = normalizeStatus(args.status);
|
|
44
460
|
const ids = await collectIds({
|
|
45
461
|
positional: args.id,
|
|
46
462
|
ids: opts.ids,
|
|
@@ -49,12 +465,15 @@ async function setReviewStatus(args, opts) {
|
|
|
49
465
|
});
|
|
50
466
|
if (ids.length === 0)
|
|
51
467
|
throw new Error('No IDs provided. Pass <id>, --ids, --ids-file, or --stdin.');
|
|
468
|
+
const patch = { status };
|
|
469
|
+
if (status === 'completed')
|
|
470
|
+
patch.reviewed_at = new Date().toISOString();
|
|
52
471
|
if (ids.length === 1 && !opts.dryRun) {
|
|
53
472
|
const { data, error } = await client
|
|
54
473
|
.from('client_monthly_reviews')
|
|
55
|
-
.update(
|
|
474
|
+
.update(patch)
|
|
56
475
|
.eq('id', ids[0])
|
|
57
|
-
.select(
|
|
476
|
+
.select(REVIEW_SELECT)
|
|
58
477
|
.single();
|
|
59
478
|
if (error)
|
|
60
479
|
throw error;
|
|
@@ -64,9 +483,9 @@ async function setReviewStatus(args, opts) {
|
|
|
64
483
|
const result = await runBulk(ids, async (id) => {
|
|
65
484
|
const { data, error } = await client
|
|
66
485
|
.from('client_monthly_reviews')
|
|
67
|
-
.update(
|
|
486
|
+
.update(patch)
|
|
68
487
|
.eq('id', id)
|
|
69
|
-
.select('id, status')
|
|
488
|
+
.select('id, status, reviewed_at')
|
|
70
489
|
.single();
|
|
71
490
|
if (error)
|
|
72
491
|
throw error;
|
|
@@ -74,6 +493,230 @@ async function setReviewStatus(args, opts) {
|
|
|
74
493
|
}, { dryRun: !!opts.dryRun, concurrency: Number(opts.concurrency ?? 5), label: 'set-status' });
|
|
75
494
|
output({ updated: result.ok.length, failed: result.failed.length, errors: result.failed.slice(0, 10) }, { pretty: !!opts.pretty });
|
|
76
495
|
}
|
|
496
|
+
async function setAnswer(_a, opts) {
|
|
497
|
+
const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
|
|
498
|
+
const questionInput = typeof opts.question === 'string' ? opts.question : null;
|
|
499
|
+
const rawValue = typeof opts.value === 'string' ? opts.value : null;
|
|
500
|
+
if (!reviewId)
|
|
501
|
+
throw new Error('--review-id is required');
|
|
502
|
+
if (!questionInput)
|
|
503
|
+
throw new Error('--question is required');
|
|
504
|
+
if (rawValue === null)
|
|
505
|
+
throw new Error('--value is required');
|
|
506
|
+
const question = QUESTION_ALIASES[questionInput] ?? questionInput;
|
|
507
|
+
const { client } = await getAuthedClient();
|
|
508
|
+
const before = await fetchReview(client, reviewId);
|
|
509
|
+
let patch = {};
|
|
510
|
+
switch (question) {
|
|
511
|
+
case 'notes':
|
|
512
|
+
patch = { notes: rawValue || null };
|
|
513
|
+
break;
|
|
514
|
+
case 'needs':
|
|
515
|
+
case 'testimonials':
|
|
516
|
+
case 'next_actions':
|
|
517
|
+
patch = { [question]: parseStringArray(rawValue, question) };
|
|
518
|
+
break;
|
|
519
|
+
case 'risk_level':
|
|
520
|
+
patch = { risk_level: rawValue.trim().toLowerCase() === 'null' ? null : normalizeRisk(rawValue) };
|
|
521
|
+
break;
|
|
522
|
+
case 'status': {
|
|
523
|
+
const status = normalizeStatus(rawValue);
|
|
524
|
+
patch = { status };
|
|
525
|
+
if (status === 'completed')
|
|
526
|
+
patch.reviewed_at = new Date().toISOString();
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case 'reviewed_at': {
|
|
530
|
+
if (rawValue.trim().toLowerCase() === 'null') {
|
|
531
|
+
patch = { reviewed_at: null };
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
const d = new Date(rawValue);
|
|
535
|
+
if (Number.isNaN(d.getTime())) {
|
|
536
|
+
throw new Error(`reviewed_at must be a valid date (got "${rawValue}"). Use ISO 8601, e.g. 2026-04-26 or 2026-04-26T10:00:00Z.`);
|
|
537
|
+
}
|
|
538
|
+
patch = { reviewed_at: d.toISOString() };
|
|
539
|
+
}
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
case 'satisfaction_score': {
|
|
543
|
+
const n = Number(rawValue);
|
|
544
|
+
if (!Number.isInteger(n) || n < 1 || n > 5)
|
|
545
|
+
throw new Error('satisfaction_score must be an integer 1..5');
|
|
546
|
+
patch = { satisfaction_score: n };
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
case 'metadata':
|
|
550
|
+
patch = { metadata: parseJsonish(rawValue) };
|
|
551
|
+
break;
|
|
552
|
+
case 'contacted': {
|
|
553
|
+
const contacted = boolFromUnknown(rawValue, '--value');
|
|
554
|
+
patch = { metadata: mergeContactedMetadata(before.metadata, contacted, before.status) };
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
default:
|
|
558
|
+
throw new Error(`Unknown question "${questionInput}". Run \`badgie-crm top-clients questions list\`.`);
|
|
559
|
+
}
|
|
560
|
+
if (opts.dryRun) {
|
|
561
|
+
output({ dry_run: true, review_id: reviewId, question, patch, before }, { pretty: !!opts.pretty });
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const { data, error } = await client
|
|
565
|
+
.from('client_monthly_reviews')
|
|
566
|
+
.update(patch)
|
|
567
|
+
.eq('id', reviewId)
|
|
568
|
+
.select(REVIEW_SELECT)
|
|
569
|
+
.single();
|
|
570
|
+
if (error)
|
|
571
|
+
throw error;
|
|
572
|
+
output(data, { pretty: !!opts.pretty });
|
|
573
|
+
}
|
|
574
|
+
async function setNotes(_a, opts) {
|
|
575
|
+
const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
|
|
576
|
+
if (!reviewId)
|
|
577
|
+
throw new Error('--review-id is required');
|
|
578
|
+
if (typeof opts.notes !== 'string')
|
|
579
|
+
throw new Error('--notes is required');
|
|
580
|
+
const { client } = await getAuthedClient();
|
|
581
|
+
const patch = { notes: opts.notes.trim() === '' ? null : opts.notes };
|
|
582
|
+
if (opts.dryRun) {
|
|
583
|
+
output({ dry_run: true, review_id: reviewId, patch }, { pretty: !!opts.pretty });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const { data, error } = await client
|
|
587
|
+
.from('client_monthly_reviews')
|
|
588
|
+
.update(patch)
|
|
589
|
+
.eq('id', reviewId)
|
|
590
|
+
.select(REVIEW_SELECT)
|
|
591
|
+
.single();
|
|
592
|
+
if (error)
|
|
593
|
+
throw error;
|
|
594
|
+
output(data, { pretty: !!opts.pretty });
|
|
595
|
+
}
|
|
596
|
+
async function setOwner(_a, opts) {
|
|
597
|
+
const reviewId = typeof opts.reviewId === 'string' ? opts.reviewId : null;
|
|
598
|
+
if (!reviewId)
|
|
599
|
+
throw new Error('--review-id is required');
|
|
600
|
+
const { client } = await getAuthedClient();
|
|
601
|
+
const kam = opts.clearKam || opts.clearOwner
|
|
602
|
+
? null
|
|
603
|
+
: await maybeResolve(client, 'team_member', opts.kam);
|
|
604
|
+
if (!opts.clearKam && !opts.clearOwner && !kam)
|
|
605
|
+
throw new Error('--kam is required (or pass --clear-kam)');
|
|
606
|
+
const patch = { reviewed_by: kam };
|
|
607
|
+
if (opts.dryRun) {
|
|
608
|
+
output({ dry_run: true, review_id: reviewId, patch }, { pretty: !!opts.pretty });
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const { data, error } = await client
|
|
612
|
+
.from('client_monthly_reviews')
|
|
613
|
+
.update(patch)
|
|
614
|
+
.eq('id', reviewId)
|
|
615
|
+
.select(REVIEW_SELECT)
|
|
616
|
+
.single();
|
|
617
|
+
if (error)
|
|
618
|
+
throw error;
|
|
619
|
+
output(data, { pretty: !!opts.pretty });
|
|
620
|
+
}
|
|
621
|
+
async function getConfig(_a, opts) {
|
|
622
|
+
const clientRef = typeof opts.clientId === 'string' ? opts.clientId : null;
|
|
623
|
+
if (!clientRef)
|
|
624
|
+
throw new Error('--client-id is required');
|
|
625
|
+
const { client } = await getAuthedClient();
|
|
626
|
+
const clientId = await resolveRef(client, 'client', clientRef);
|
|
627
|
+
const { data, error } = await client
|
|
628
|
+
.from('clients')
|
|
629
|
+
.select(CLIENT_CONFIG_SELECT)
|
|
630
|
+
.eq('id', clientId)
|
|
631
|
+
.single();
|
|
632
|
+
if (error)
|
|
633
|
+
throw error;
|
|
634
|
+
output({
|
|
635
|
+
client: data,
|
|
636
|
+
top_clients_scope: {
|
|
637
|
+
included_when_status_in: ACTIVE_CLIENT_STATUSES,
|
|
638
|
+
currently_included: ACTIVE_CLIENT_STATUSES.includes(data.status),
|
|
639
|
+
},
|
|
640
|
+
supported_config_fields: [
|
|
641
|
+
'clients.status',
|
|
642
|
+
'clients.assigned_to',
|
|
643
|
+
'clients.plan_type',
|
|
644
|
+
'clients.plan_student_tier',
|
|
645
|
+
'clients.plan_student_count',
|
|
646
|
+
'clients.plan_payment_frequency',
|
|
647
|
+
'clients.plan_payment_amount',
|
|
648
|
+
],
|
|
649
|
+
no_dedicated_schema_fields: [
|
|
650
|
+
'review periodicity',
|
|
651
|
+
'active questions per client',
|
|
652
|
+
'top-client priority',
|
|
653
|
+
],
|
|
654
|
+
note: 'The current UI derives Top Clients from active clients and stores monthly answers in client_monthly_reviews.',
|
|
655
|
+
}, { pretty: !!opts.pretty });
|
|
656
|
+
}
|
|
657
|
+
async function setConfig(_a, opts) {
|
|
658
|
+
const clientRef = typeof opts.clientId === 'string' ? opts.clientId : null;
|
|
659
|
+
if (!clientRef)
|
|
660
|
+
throw new Error('--client-id is required');
|
|
661
|
+
const { client } = await getAuthedClient();
|
|
662
|
+
const clientId = await resolveRef(client, 'client', clientRef);
|
|
663
|
+
const patch = {};
|
|
664
|
+
if (typeof opts.status === 'string')
|
|
665
|
+
patch.status = opts.status;
|
|
666
|
+
if (opts.topClient !== undefined && patch.status === undefined) {
|
|
667
|
+
patch.status = boolFromUnknown(opts.topClient, '--top-client') ? 'active' : 'inactive';
|
|
668
|
+
}
|
|
669
|
+
if (opts.clearKam) {
|
|
670
|
+
patch.assigned_to = null;
|
|
671
|
+
}
|
|
672
|
+
else if (typeof opts.kam === 'string') {
|
|
673
|
+
patch.assigned_to = await resolveRef(client, 'team_member', opts.kam);
|
|
674
|
+
}
|
|
675
|
+
const planType = nullableString(opts.planType);
|
|
676
|
+
if (planType !== undefined)
|
|
677
|
+
patch.plan_type = planType;
|
|
678
|
+
const planStudentTier = nullableString(opts.planStudentTier);
|
|
679
|
+
if (planStudentTier !== undefined)
|
|
680
|
+
patch.plan_student_tier = planStudentTier;
|
|
681
|
+
const planPaymentFrequency = nullableString(opts.planPaymentFrequency);
|
|
682
|
+
if (planPaymentFrequency !== undefined)
|
|
683
|
+
patch.plan_payment_frequency = planPaymentFrequency;
|
|
684
|
+
const planStudentCount = numberOrNull(opts.planStudentCount, '--plan-student-count');
|
|
685
|
+
if (planStudentCount !== undefined)
|
|
686
|
+
patch.plan_student_count = planStudentCount;
|
|
687
|
+
const planPaymentAmount = numberOrNull(opts.planPaymentAmount, '--plan-payment-amount');
|
|
688
|
+
if (planPaymentAmount !== undefined)
|
|
689
|
+
patch.plan_payment_amount = planPaymentAmount;
|
|
690
|
+
if (Object.keys(patch).length === 0) {
|
|
691
|
+
throw new Error('Pass at least one config field to update. Run `badgie-crm top-clients config get --client-id <client>`.');
|
|
692
|
+
}
|
|
693
|
+
if (opts.dryRun) {
|
|
694
|
+
output({ dry_run: true, client_id: clientId, patch }, { pretty: !!opts.pretty });
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const { data, error } = await client
|
|
698
|
+
.from('clients')
|
|
699
|
+
.update({ ...patch, updated_at: new Date().toISOString() })
|
|
700
|
+
.eq('id', clientId)
|
|
701
|
+
.select(CLIENT_CONFIG_SELECT)
|
|
702
|
+
.single();
|
|
703
|
+
if (error)
|
|
704
|
+
throw error;
|
|
705
|
+
if ('assigned_to' in patch) {
|
|
706
|
+
const actorId = await getCurrentTeamMemberId(client).catch(() => null);
|
|
707
|
+
await client.from('activities').insert({
|
|
708
|
+
client_id: clientId,
|
|
709
|
+
type: 'note',
|
|
710
|
+
title: 'Cambio de KAM',
|
|
711
|
+
description: `KAM actualizado desde badgie-crm CLI`,
|
|
712
|
+
created_by: actorId,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
output(data, { pretty: !!opts.pretty });
|
|
716
|
+
}
|
|
717
|
+
const prettyOption = { flag: '--pretty', description: 'human output' };
|
|
718
|
+
const jsonOption = { flag: '--json', description: 'force JSON output (default)' };
|
|
719
|
+
const dryRunOption = { flag: '--dry-run', description: 'show what would happen without writing' };
|
|
77
720
|
export const topClientsModule = {
|
|
78
721
|
name: 'top-clients',
|
|
79
722
|
summary: 'Monthly top-client reviews (KAM follow-up tracker)',
|
|
@@ -85,28 +728,182 @@ export const topClientsModule = {
|
|
|
85
728
|
options: [
|
|
86
729
|
{ flag: '--month <n>', description: '1-12 (default current month)' },
|
|
87
730
|
{ flag: '--year <n>', description: 'YYYY (default current year)' },
|
|
88
|
-
{ flag: '--kam <uuid|name|email>', description: 'filter by
|
|
89
|
-
{ flag: '--
|
|
731
|
+
{ flag: '--kam <uuid|name|email>', description: 'filter by client KAM (clients.assigned_to) or review owner (reviewed_by)' },
|
|
732
|
+
{ flag: '--reviewer <uuid|name|email>', description: 'filter strictly by reviewed_by (team member)' },
|
|
733
|
+
{ flag: '--status <s>', description: 'pending|in_progress|completed' },
|
|
90
734
|
{ flag: '--search <text>', description: 'filter by client name (client-side)' },
|
|
91
735
|
...commonListOptions(),
|
|
92
736
|
],
|
|
93
737
|
examples: [
|
|
94
|
-
'badgie-crm top-clients list --month
|
|
95
|
-
'badgie-crm top-clients list --kam
|
|
738
|
+
'badgie-crm top-clients list --month 4 --year 2026 --limit 10',
|
|
739
|
+
'badgie-crm top-clients list --month 4 --year 2026 --kam Greg --status pending --pretty',
|
|
96
740
|
],
|
|
97
741
|
handler: listTopClients,
|
|
98
742
|
},
|
|
743
|
+
{
|
|
744
|
+
path: ['top-clients', 'get'],
|
|
745
|
+
summary: 'Show one monthly review with client and reviewer joined',
|
|
746
|
+
tags: ['read'],
|
|
747
|
+
args: [{ name: 'id', required: true, description: 'client_monthly_reviews.id' }],
|
|
748
|
+
options: [prettyOption, jsonOption],
|
|
749
|
+
examples: ['badgie-crm top-clients get <review-id> --pretty'],
|
|
750
|
+
handler: getTopClient,
|
|
751
|
+
},
|
|
752
|
+
{
|
|
753
|
+
path: ['top-clients', 'questions', 'list'],
|
|
754
|
+
summary: 'List review questions/answer fields used by the current UI',
|
|
755
|
+
tags: ['read'],
|
|
756
|
+
options: [prettyOption, jsonOption],
|
|
757
|
+
examples: ['badgie-crm top-clients questions list'],
|
|
758
|
+
handler: listQuestions,
|
|
759
|
+
},
|
|
760
|
+
{
|
|
761
|
+
path: ['top-clients', 'answers', 'list'],
|
|
762
|
+
summary: 'List stored answers for one review',
|
|
763
|
+
tags: ['read'],
|
|
764
|
+
options: [
|
|
765
|
+
{ flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
|
|
766
|
+
...commonListOptions(),
|
|
767
|
+
],
|
|
768
|
+
examples: ['badgie-crm top-clients answers list --review-id <review-id> --pretty'],
|
|
769
|
+
handler: listAnswers,
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
path: ['top-clients', 'summary'],
|
|
773
|
+
summary: 'Monthly Top Clients summary stats',
|
|
774
|
+
tags: ['read'],
|
|
775
|
+
options: [
|
|
776
|
+
{ flag: '--month <n>', description: '1-12 (default current month)' },
|
|
777
|
+
{ flag: '--year <n>', description: 'YYYY (default current year)' },
|
|
778
|
+
prettyOption,
|
|
779
|
+
jsonOption,
|
|
780
|
+
],
|
|
781
|
+
examples: ['badgie-crm top-clients summary --month 4 --year 2026'],
|
|
782
|
+
handler: summary,
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
path: ['top-clients', 'generate'],
|
|
786
|
+
summary: 'Create or preview monthly reviews for active Top Clients',
|
|
787
|
+
description: 'Top Clients scope matches the UI: clients whose status is active or active free trial. Existing reviews for the same client/month/year are not duplicated.',
|
|
788
|
+
tags: ['write'],
|
|
789
|
+
options: [
|
|
790
|
+
{ flag: '--month <n>', description: '1-12 (default current month)' },
|
|
791
|
+
{ flag: '--year <n>', description: 'YYYY (default current year)' },
|
|
792
|
+
dryRunOption,
|
|
793
|
+
{ flag: '--limit <n>', description: 'max preview rows for --dry-run (default 20)' },
|
|
794
|
+
prettyOption,
|
|
795
|
+
jsonOption,
|
|
796
|
+
],
|
|
797
|
+
examples: [
|
|
798
|
+
'badgie-crm top-clients generate --month 4 --year 2026 --dry-run',
|
|
799
|
+
'badgie-crm top-clients generate --month 4 --year 2026 --pretty',
|
|
800
|
+
],
|
|
801
|
+
handler: generate,
|
|
802
|
+
},
|
|
99
803
|
{
|
|
100
804
|
path: ['top-clients', 'set-status'],
|
|
101
805
|
summary: 'Change review status (single or bulk)',
|
|
102
806
|
tags: ['write'],
|
|
103
807
|
args: [
|
|
104
|
-
{ name: 'status', required: true, description: '
|
|
808
|
+
{ name: 'status', required: true, description: 'pending|in_progress|completed' },
|
|
105
809
|
{ name: 'id', required: false, description: 'client_monthly_reviews.id (or use --ids / --stdin)' },
|
|
106
810
|
],
|
|
107
|
-
options: [...bulkOptions,
|
|
811
|
+
options: [...bulkOptions, prettyOption, jsonOption],
|
|
812
|
+
examples: [
|
|
813
|
+
'badgie-crm top-clients set-status completed <review-id>',
|
|
814
|
+
'badgie-crm top-clients set-status in_progress --ids "<id1>,<id2>" --dry-run',
|
|
815
|
+
],
|
|
108
816
|
handler: setReviewStatus,
|
|
109
817
|
},
|
|
818
|
+
{
|
|
819
|
+
path: ['top-clients', 'answer', 'set'],
|
|
820
|
+
summary: 'Set one answer field on a review',
|
|
821
|
+
description: 'Question can be notes, needs, testimonials, risk_level, next_actions, status, reviewed_at, satisfaction_score, metadata, or contacted. Arrays accept JSON arrays or comma/newline-separated text.',
|
|
822
|
+
tags: ['write'],
|
|
823
|
+
options: [
|
|
824
|
+
{ flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
|
|
825
|
+
{ flag: '--question <key|id>', description: 'question key from `top-clients questions list` (required)' },
|
|
826
|
+
{ flag: '--value <text|json>', description: 'answer value (required)' },
|
|
827
|
+
dryRunOption,
|
|
828
|
+
prettyOption,
|
|
829
|
+
jsonOption,
|
|
830
|
+
],
|
|
831
|
+
examples: [
|
|
832
|
+
'badgie-crm top-clients answer set --review-id <id> --question risk_level --value high',
|
|
833
|
+
'badgie-crm top-clients answer set --review-id <id> --question needs --value \'["Más demos", "Soporte técnico"]\'',
|
|
834
|
+
'badgie-crm top-clients answer set --review-id <id> --question contacted --value true',
|
|
835
|
+
],
|
|
836
|
+
handler: setAnswer,
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
path: ['top-clients', 'notes', 'set'],
|
|
840
|
+
summary: 'Set review notes',
|
|
841
|
+
tags: ['write'],
|
|
842
|
+
options: [
|
|
843
|
+
{ flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
|
|
844
|
+
{ flag: '--notes <text>', description: 'notes text (required, empty string clears)' },
|
|
845
|
+
dryRunOption,
|
|
846
|
+
prettyOption,
|
|
847
|
+
jsonOption,
|
|
848
|
+
],
|
|
849
|
+
examples: ['badgie-crm top-clients notes set --review-id <id> --notes "Cliente estable, revisar renovacion"'],
|
|
850
|
+
handler: setNotes,
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
path: ['top-clients', 'owner', 'set'],
|
|
854
|
+
summary: 'Set the review owner/reviewer (reviewed_by)',
|
|
855
|
+
tags: ['write'],
|
|
856
|
+
options: [
|
|
857
|
+
{ flag: '--review-id <id>', description: 'client_monthly_reviews.id (required)' },
|
|
858
|
+
{ flag: '--kam <uuid|name|email>', description: 'team member assigned as reviewed_by' },
|
|
859
|
+
{ flag: '--clear-kam', description: 'clear reviewed_by' },
|
|
860
|
+
{ flag: '--clear-owner', description: 'alias for --clear-kam' },
|
|
861
|
+
dryRunOption,
|
|
862
|
+
prettyOption,
|
|
863
|
+
jsonOption,
|
|
864
|
+
],
|
|
865
|
+
examples: ['badgie-crm top-clients owner set --review-id <id> --kam Greg --pretty'],
|
|
866
|
+
handler: setOwner,
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
path: ['top-clients', 'config', 'get'],
|
|
870
|
+
summary: 'Show Top Clients configuration derived from an existing client row',
|
|
871
|
+
description: 'The current UI has no dedicated top-client config table. Inclusion is derived from clients.status, KAM from clients.assigned_to, and answers from client_monthly_reviews.',
|
|
872
|
+
tags: ['read'],
|
|
873
|
+
options: [
|
|
874
|
+
{ flag: '--client-id <uuid|name|email>', description: 'client reference (required)' },
|
|
875
|
+
prettyOption,
|
|
876
|
+
jsonOption,
|
|
877
|
+
],
|
|
878
|
+
examples: ['badgie-crm top-clients config get --client-id "Club Example"'],
|
|
879
|
+
handler: getConfig,
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
path: ['top-clients', 'config', 'set'],
|
|
883
|
+
summary: 'Update Top Clients-related client configuration fields',
|
|
884
|
+
description: 'Updates only existing client columns used by the UI: status/top-client inclusion, assigned KAM, and plan fields. There are no dedicated DB columns for review periodicity, active questions, or top-client priority.',
|
|
885
|
+
tags: ['write'],
|
|
886
|
+
options: [
|
|
887
|
+
{ flag: '--client-id <uuid|name|email>', description: 'client reference (required)' },
|
|
888
|
+
{ flag: '--kam <uuid|name|email>', description: 'set clients.assigned_to' },
|
|
889
|
+
{ flag: '--clear-kam', description: 'clear clients.assigned_to' },
|
|
890
|
+
{ flag: '--status <s>', description: 'set clients.status directly' },
|
|
891
|
+
{ flag: '--top-client <bool>', description: 'true => status active, false => status inactive (ignored if --status is passed)' },
|
|
892
|
+
{ flag: '--plan-type <value>', description: 'set clients.plan_type (empty/null clears)' },
|
|
893
|
+
{ flag: '--plan-student-tier <value>', description: 'set clients.plan_student_tier (empty/null clears)' },
|
|
894
|
+
{ flag: '--plan-student-count <n>', description: 'set clients.plan_student_count (empty/null clears)' },
|
|
895
|
+
{ flag: '--plan-payment-frequency <value>', description: 'set clients.plan_payment_frequency (empty/null clears)' },
|
|
896
|
+
{ flag: '--plan-payment-amount <n>', description: 'set clients.plan_payment_amount (empty/null clears)' },
|
|
897
|
+
dryRunOption,
|
|
898
|
+
prettyOption,
|
|
899
|
+
jsonOption,
|
|
900
|
+
],
|
|
901
|
+
examples: [
|
|
902
|
+
'badgie-crm top-clients config set --client-id "Club Example" --kam Greg --dry-run',
|
|
903
|
+
'badgie-crm top-clients config set --client-id <client-id> --top-client true --plan-payment-frequency monthly',
|
|
904
|
+
],
|
|
905
|
+
handler: setConfig,
|
|
906
|
+
},
|
|
110
907
|
],
|
|
111
908
|
};
|
|
112
909
|
//# sourceMappingURL=top-clients.js.map
|