This commit is contained in:
@ -19,6 +19,17 @@
|
|||||||
"client",
|
"client",
|
||||||
"client_code",
|
"client_code",
|
||||||
"client_code_exists",
|
"client_code_exists",
|
||||||
|
"client_note",
|
||||||
|
"client_note_add",
|
||||||
|
"client_note_add_action",
|
||||||
|
"client_note_edit",
|
||||||
|
"client_note_for_client",
|
||||||
|
"client_note_hint",
|
||||||
|
"client_note_pending",
|
||||||
|
"client_note_saved",
|
||||||
|
"client_note_saved_pending",
|
||||||
|
"client_note_title",
|
||||||
|
"client_note_too_long",
|
||||||
"coach_code",
|
"coach_code",
|
||||||
"confirm_password_hint",
|
"confirm_password_hint",
|
||||||
"date_after",
|
"date_after",
|
||||||
@ -62,6 +73,7 @@
|
|||||||
"logout_offline_message",
|
"logout_offline_message",
|
||||||
"logout_offline_title",
|
"logout_offline_title",
|
||||||
"logout_pending_detail_in_progress",
|
"logout_pending_detail_in_progress",
|
||||||
|
"logout_pending_detail_notes",
|
||||||
"logout_pending_detail_questionnaires",
|
"logout_pending_detail_questionnaires",
|
||||||
"logout_pending_detail_scoring",
|
"logout_pending_detail_scoring",
|
||||||
"logout_pending_message",
|
"logout_pending_message",
|
||||||
@ -148,6 +160,7 @@
|
|||||||
"upload_prepare_failed",
|
"upload_prepare_failed",
|
||||||
"upload_scoring_review_summary",
|
"upload_scoring_review_summary",
|
||||||
"upload_summary",
|
"upload_summary",
|
||||||
|
"upload_success_client_notes",
|
||||||
"upload_success_message",
|
"upload_success_message",
|
||||||
"upload_success_questionnaires",
|
"upload_success_questionnaires",
|
||||||
"upload_success_scoring_reviews",
|
"upload_success_scoring_reviews",
|
||||||
@ -174,6 +187,17 @@
|
|||||||
"client": "Klient",
|
"client": "Klient",
|
||||||
"client_code": "Klientencode",
|
"client_code": "Klientencode",
|
||||||
"client_code_exists": "Dieser Client-Code existiert bereits.",
|
"client_code_exists": "Dieser Client-Code existiert bereits.",
|
||||||
|
"client_note": "Klient-Notiz",
|
||||||
|
"client_note_add": "Noch keine Notiz für diesen Klienten — tippen zum Hinzufügen",
|
||||||
|
"client_note_add_action": "Hinzufügen",
|
||||||
|
"client_note_edit": "Bearbeiten",
|
||||||
|
"client_note_for_client": "Notiz für {code}",
|
||||||
|
"client_note_hint": "Nur für diesen Klienten · bis zu 200 Zeichen",
|
||||||
|
"client_note_pending": "Upload ausstehend",
|
||||||
|
"client_note_saved": "Notiz für diesen Klienten gespeichert",
|
||||||
|
"client_note_saved_pending": "Notiz für diesen Klienten gespeichert — wird beim Upload gesendet",
|
||||||
|
"client_note_title": "Notiz für diesen Klienten",
|
||||||
|
"client_note_too_long": "Notiz darf höchstens 200 Zeichen haben",
|
||||||
"coach_code": "Berater-Code",
|
"coach_code": "Berater-Code",
|
||||||
"confirm_password_hint": "Passwort bestätigen",
|
"confirm_password_hint": "Passwort bestätigen",
|
||||||
"date_after": "Das Datum muss nach",
|
"date_after": "Das Datum muss nach",
|
||||||
@ -217,6 +241,7 @@
|
|||||||
"logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.",
|
"logout_offline_message": "Sie sind offline. Stellen Sie eine Internetverbindung her, laden Sie ausstehende Daten hoch und versuchen Sie es dann erneut.",
|
||||||
"logout_offline_title": "Abmelden nicht möglich",
|
"logout_offline_title": "Abmelden nicht möglich",
|
||||||
"logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten",
|
"logout_pending_detail_in_progress": "{count} begonnene(r) Fragebogen mit lokalen Antworten",
|
||||||
|
"logout_pending_detail_notes": "{count} Notiz(en) warten auf Upload",
|
||||||
"logout_pending_detail_questionnaires": "{count} abgeschlossene(r) Fragebogen warten auf Upload",
|
"logout_pending_detail_questionnaires": "{count} abgeschlossene(r) Fragebogen warten auf Upload",
|
||||||
"logout_pending_detail_scoring": "{count} Punkteprüfung(en) warten auf Upload",
|
"logout_pending_detail_scoring": "{count} Punkteprüfung(en) warten auf Upload",
|
||||||
"logout_pending_message": "Auf diesem Gerät liegen noch nicht hochgeladene Daten vor. Bitte tippen Sie auf „Hochladen“ und warten Sie, bis der Upload abgeschlossen ist. Schließen Sie begonnene Fragebögen zuerst ab.",
|
"logout_pending_message": "Auf diesem Gerät liegen noch nicht hochgeladene Daten vor. Bitte tippen Sie auf „Hochladen“ und warten Sie, bis der Upload abgeschlossen ist. Schließen Sie begonnene Fragebögen zuerst ab.",
|
||||||
@ -302,7 +327,8 @@
|
|||||||
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
"upload_nothing_pending": "Es wurde nichts hochgeladen. Es liegen keine abgeschlossenen Fragebögen zum Upload vor. Bitte zuerst einen Fragebogen abschließen.",
|
||||||
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
"upload_prepare_failed": "Es wurde nichts hochgeladen. Die Daten konnten nicht für den Upload vorbereitet werden. Bitte erneut synchronisieren oder den Support kontaktieren.",
|
||||||
"upload_scoring_review_summary": "Berater: {band} · Summe {total}",
|
"upload_scoring_review_summary": "Berater: {band} · Summe {total}",
|
||||||
"upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {clients} Klienten",
|
"upload_summary": "{qn} Fragebögen · {scores} Punkteprüfungen · {notes} Notizen · {clients} Klienten",
|
||||||
|
"upload_success_client_notes": "{count} Notiz(en)",
|
||||||
"upload_success_message": "Hochladen: {count} erledigt.",
|
"upload_success_message": "Hochladen: {count} erledigt.",
|
||||||
"upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
|
"upload_success_questionnaires": "{count} Fragebogen/Fragebögen",
|
||||||
"upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
|
"upload_success_scoring_reviews": "{count} Punkteprüfung(en)",
|
||||||
|
|||||||
@ -309,6 +309,7 @@ Success:
|
|||||||
"clients": [
|
"clients": [
|
||||||
{
|
{
|
||||||
"clientCode": "CLIENT-001",
|
"clientCode": "CLIENT-001",
|
||||||
|
"note": "Call back next week",
|
||||||
"completedQuestionnaires": [
|
"completedQuestionnaires": [
|
||||||
{
|
{
|
||||||
"questionnaireID": "questionnaire_1_demographic_information",
|
"questionnaireID": "questionnaire_1_demographic_information",
|
||||||
@ -319,6 +320,7 @@ Success:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"clientCode": "CLIENT-002",
|
"clientCode": "CLIENT-002",
|
||||||
|
"note": "",
|
||||||
"completedQuestionnaires": []
|
"completedQuestionnaires": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -330,6 +332,7 @@ Fields:
|
|||||||
|
|
||||||
- `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`.
|
- `clients`: array of client objects assigned to the coach, ordered alphabetically by `clientCode`.
|
||||||
- `clients[].clientCode`: the client's code string.
|
- `clients[].clientCode`: the client's code string.
|
||||||
|
- `clients[].note`: counselor follow-up note for this client (max 200 characters; empty string when none).
|
||||||
- `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none.
|
- `clients[].completedQuestionnaires`: array of questionnaires already completed by this client. Empty array if none.
|
||||||
- `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint.
|
- `clients[].completedQuestionnaires[].questionnaireID`: the questionnaire ID, matching the IDs from the questionnaire list endpoint.
|
||||||
- `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire.
|
- `clients[].completedQuestionnaires[].sumPoints`: total points from the last upload for this questionnaire.
|
||||||
@ -444,6 +447,24 @@ Allowed roles: `admin`, `supervisor`, `coach`.
|
|||||||
|
|
||||||
The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client.
|
The authenticated user must be authorized for the submitted `clientCode`. A coach can upload for their own clients. A supervisor can upload for clients of their coaches. An admin can upload for any client.
|
||||||
|
|
||||||
|
### Set client note
|
||||||
|
|
||||||
|
Encrypted body action (coach only):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "setClientNote",
|
||||||
|
"clientCode": "CLIENT-001",
|
||||||
|
"note": "Call back next week"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `note` is optional; omit or send `""` to clear. Max **200** characters after sanitization (same free-text rules as interview answers).
|
||||||
|
- Response `data` is an encrypted envelope containing `{ "clientCode", "note" }`.
|
||||||
|
- Notes are stored in `client_followup_note` (shared with the web Insights follow-up list).
|
||||||
|
|
||||||
|
### Submit questionnaire answers
|
||||||
|
|
||||||
Request:
|
Request:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@ -33,7 +33,7 @@ case 'GET':
|
|||||||
case 'PUT':
|
case 'PUT':
|
||||||
$body = read_json_body();
|
$body = read_json_body();
|
||||||
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
$clientCode = trim((string)($body['clientCode'] ?? ''));
|
||||||
$note = (string)($body['note'] ?? '');
|
$note = qdb_normalize_client_followup_note($body['note'] ?? '');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$opened = qdb_open_write_or_fail();
|
$opened = qdb_open_write_or_fail();
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
|
* GET ?scoringProfiles=1 -> active scoring profile definitions (encrypted)
|
||||||
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
|
* GET ?scoringReview=1 -> coach review state (coachBand only; computed on device)
|
||||||
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
|
* POST action=coachScoringReview -> coach agrees with or overrides calculated band (encrypted)
|
||||||
|
* POST action=setClientNote -> upsert counselor note for an assigned client (max 200 chars, encrypted)
|
||||||
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
* POST -> submit interview answers for a client (coach / supervisor / admin).
|
||||||
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
* Re-posting the same clientCode + questionnaireID updates answers in place (re-edit).
|
||||||
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
* Sensitive POST/GET ?clients=1 bodies use encrypted payloads (HKDF from Bearer token).
|
||||||
@ -85,6 +86,27 @@ if ($method === 'POST') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (($body['action'] ?? '') === 'setClientNote') {
|
||||||
|
require_role(['coach'], $tokenRec);
|
||||||
|
require_fields($body, ['clientCode']);
|
||||||
|
$clientCode = trim((string)$body['clientCode']);
|
||||||
|
require_once __DIR__ . '/../lib/analytics.php';
|
||||||
|
$note = qdb_normalize_client_followup_note($body['note'] ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$opened = qdb_open_write_or_fail();
|
||||||
|
[$pdo, $tmpDb, $lockFp] = $opened;
|
||||||
|
qdb_analytics_set_followup_note($pdo, $tokenRec, $clientCode, $note);
|
||||||
|
qdb_save($tmpDb, $lockFp);
|
||||||
|
json_success_sensitive(
|
||||||
|
['clientCode' => $clientCode, 'note' => $note],
|
||||||
|
$tokenRec['_token']
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
qdb_handler_fail($e, 'Save client note', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
require_fields($body, ['questionnaireID', 'clientCode', 'answers']);
|
||||||
|
|
||||||
$qnID = trim($body['questionnaireID']);
|
$qnID = trim($body['questionnaireID']);
|
||||||
@ -462,6 +484,7 @@ if ($fetchClients) {
|
|||||||
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
$clientCodes = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
$completions = [];
|
$completions = [];
|
||||||
|
$notesByClient = [];
|
||||||
if (!empty($clientCodes)) {
|
if (!empty($clientCodes)) {
|
||||||
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
$placeholders = implode(',', array_fill(0, count($clientCodes), '?'));
|
||||||
$stmt2 = $pdo->prepare("
|
$stmt2 = $pdo->prepare("
|
||||||
@ -477,12 +500,23 @@ if ($fetchClients) {
|
|||||||
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
|
'completedAt' => $row['completedAt'] !== null ? (int) $row['completedAt'] : null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (qdb_table_exists($pdo, 'client_followup_note')) {
|
||||||
|
$stmtNotes = $pdo->prepare(
|
||||||
|
"SELECT clientCode, note FROM client_followup_note WHERE clientCode IN ($placeholders)"
|
||||||
|
);
|
||||||
|
$stmtNotes->execute($clientCodes);
|
||||||
|
foreach ($stmtNotes->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$notesByClient[$row['clientCode']] = (string)($row['note'] ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$clients = array_map(function ($code) use ($completions) {
|
$clients = array_map(function ($code) use ($completions, $notesByClient) {
|
||||||
return [
|
return [
|
||||||
'clientCode' => $code,
|
'clientCode' => $code,
|
||||||
'completedQuestionnaires' => $completions[$code] ?? [],
|
'completedQuestionnaires' => $completions[$code] ?? [],
|
||||||
|
'note' => $notesByClient[$code] ?? '',
|
||||||
];
|
];
|
||||||
}, $clientCodes);
|
}, $clientCodes);
|
||||||
|
|
||||||
|
|||||||
@ -240,6 +240,37 @@ function qdb_analytics_stale_clients(PDO $pdo, array $tokenRec): array {
|
|||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max length for counselor/admin client follow-up notes (characters). */
|
||||||
|
const QDB_CLIENT_NOTE_MAX_LENGTH = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a client follow-up note. Empty string clears the note.
|
||||||
|
* Rejects input that sanitizes to empty while the raw value was non-empty.
|
||||||
|
*/
|
||||||
|
function qdb_normalize_client_followup_note(mixed $note): string {
|
||||||
|
require_once __DIR__ . '/text_sanitize.php';
|
||||||
|
$raw = is_string($note) || is_numeric($note) ? trim((string)$note) : '';
|
||||||
|
if ($raw === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (mb_strlen($raw) > QDB_CLIENT_NOTE_MAX_LENGTH) {
|
||||||
|
json_error(
|
||||||
|
'INVALID_FIELD',
|
||||||
|
'note must be at most ' . QDB_CLIENT_NOTE_MAX_LENGTH . ' characters',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$clean = sanitize_free_text($raw, QDB_CLIENT_NOTE_MAX_LENGTH);
|
||||||
|
if ($clean === '') {
|
||||||
|
json_error('INVALID_FIELD', 'note contains disallowed content', 400);
|
||||||
|
}
|
||||||
|
return $clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a follow-up note for a client visible under the caller's RBAC.
|
||||||
|
* Pass an already-normalized note (see qdb_normalize_client_followup_note).
|
||||||
|
*/
|
||||||
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
|
function qdb_analytics_set_followup_note(PDO $pdo, array $tokenRec, string $clientCode, string $note): void {
|
||||||
$clientCode = trim($clientCode);
|
$clientCode = trim($clientCode);
|
||||||
if ($clientCode === '') {
|
if ($clientCode === '') {
|
||||||
|
|||||||
@ -58,4 +58,42 @@ final class AppQuestionnairesTest extends QdbTestCase
|
|||||||
$codes = array_column($payload['clients'], 'clientCode');
|
$codes = array_column($payload['clients'], 'clientCode');
|
||||||
$this->assertContains($this->fixture()->clientCode, $codes);
|
$this->assertContains($this->fixture()->clientCode, $codes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCoachClientNoteRoundTrip(): void
|
||||||
|
{
|
||||||
|
$this->submitFixtureQuestionnaire();
|
||||||
|
$login = $this->api()->loginMobileAndGetToken(
|
||||||
|
DatabaseSeeder::COACH_USERNAME,
|
||||||
|
DatabaseSeeder::PASSWORD
|
||||||
|
);
|
||||||
|
$code = $this->fixture()->clientCode;
|
||||||
|
$note = 'Bitte Rückruf nächste Woche';
|
||||||
|
|
||||||
|
$save = $this->api()->encryptedPost($login['token'], 'app_questionnaires', [
|
||||||
|
'action' => 'setClientNote',
|
||||||
|
'clientCode' => $code,
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
$this->assertApiOk($save);
|
||||||
|
$this->assertTrue($save['data']['encrypted'] ?? false);
|
||||||
|
$savedPlain = qdb_decrypt_sensitive_envelope($save['data'], $login['token']);
|
||||||
|
$savedPayload = json_decode($savedPlain, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertSame($note, $savedPayload['note']);
|
||||||
|
|
||||||
|
$list = $this->api()->withMobileToken($login['token'], 'GET', 'app_questionnaires', null, [
|
||||||
|
'clients' => '1',
|
||||||
|
]);
|
||||||
|
$this->assertApiOk($list);
|
||||||
|
$listPlain = qdb_decrypt_sensitive_envelope($list['data'], $login['token']);
|
||||||
|
$listPayload = json_decode($listPlain, true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
$match = null;
|
||||||
|
foreach ($listPayload['clients'] as $row) {
|
||||||
|
if (($row['clientCode'] ?? '') === $code) {
|
||||||
|
$match = $row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertIsArray($match);
|
||||||
|
$this->assertSame($note, $match['note'] ?? null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -83,4 +83,18 @@ final class ResultsAnalyticsTest extends QdbTestCase
|
|||||||
$this->assertIsArray($match);
|
$this->assertIsArray($match);
|
||||||
$this->assertSame($note, $match['note']);
|
$this->assertSame($note, $match['note']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testFollowUpNoteRejectsTooLong(): void
|
||||||
|
{
|
||||||
|
$this->submitFixtureQuestionnaire();
|
||||||
|
$token = $this->adminToken();
|
||||||
|
$code = $this->fixture()->clientCode;
|
||||||
|
$note = str_repeat('a', 201);
|
||||||
|
$save = $this->api()->withToken($token, 'PUT', 'analytics', [
|
||||||
|
'clientCode' => $code,
|
||||||
|
'note' => $note,
|
||||||
|
]);
|
||||||
|
$this->assertFalse($save['ok'] ?? true);
|
||||||
|
$this->assertSame('INVALID_FIELD', $save['error']['code'] ?? null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,7 +218,7 @@ function followupRowHTML(c) {
|
|||||||
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
|
<td>${c.daysSinceLastActivity != null ? c.daysSinceLastActivity : '—'}</td>
|
||||||
<td class="followup-note-cell">
|
<td class="followup-note-cell">
|
||||||
<textarea class="followup-note-input" rows="2" data-client="${code}"
|
<textarea class="followup-note-input" rows="2" data-client="${code}"
|
||||||
placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
|
maxlength="200" placeholder="Follow-up note…">${esc(c.note || '')}</textarea>
|
||||||
<div class="followup-note-actions">
|
<div class="followup-note-actions">
|
||||||
<button type="button" class="btn btn-sm btn-primary save-note-btn" data-client="${code}">Save note</button>
|
<button type="button" class="btn btn-sm btn-primary save-note-btn" data-client="${code}">Save note</button>
|
||||||
</div>
|
</div>
|
||||||
@ -282,10 +282,17 @@ async function archiveFromFollowup(clientCode) {
|
|||||||
|
|
||||||
async function saveNote(clientCode, ta) {
|
async function saveNote(clientCode, ta) {
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
|
const note = ta.value.trim();
|
||||||
|
if (note.length > 200) {
|
||||||
|
showToast('Note must be at most 200 characters', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiPut('analytics.php', { clientCode, note: ta.value.trim() });
|
const res = await apiPut('analytics.php', { clientCode, note });
|
||||||
|
const saved = res?.note ?? note;
|
||||||
|
ta.value = saved;
|
||||||
const row = staleClients.find(c => c.clientCode === clientCode);
|
const row = staleClients.find(c => c.clientCode === clientCode);
|
||||||
if (row) row.note = ta.value.trim();
|
if (row) row.note = saved;
|
||||||
showToast('Note saved', 'success');
|
showToast('Note saved', 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user