Make Client/Facture/Libellé editable in place, same as the type badge
New POST /lignes/{node}/champ endpoint (LedgerActionsController::updateField(),
whitelisted to client/facture/libelle -> field_client/field_numero_facture/
field_notes) mirrors updateType(): skips the répartition invariant check
for this save (client/facture/libellé never touch montant_ht or
field_repartition, so it can only ever leave a pre-existing historical
mismatch as it was, never introduce one), wrapped in the same
skip_validation state flag with a try/finally.
Client resolves the typed text against existing "Client" taxonomy terms
only (same known-names list the toolbar's Client filter already offers
via a datalist) -- a non-match is rejected with a clear error rather
than silently creating a new term from a typo.
Frontend mirrors the existing editingTypeId/startEditType/saveType
pattern exactly, generalized to any of the three fields via a single
{id, field} editingCell state.
Verified live: editing all three fields on a row with a known
répartition écart succeeds (bypass confirmed), an unknown client name
is rejected with a visible error and the display value stays unchanged,
and the database was confirmed clean of test artifacts afterward.
This commit is contained in:
@@ -323,6 +323,28 @@ html.gin--dark-mode #figli-home-app {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Client/Facture/Libellé: click-to-edit like the type badge above, but
|
||||
plain text rather than a pill -- a dotted underline is enough of an
|
||||
affordance without implying a fixed set of choices the way the type
|
||||
badge's pill shape does. */
|
||||
#figli-home-app .figli-editable-cell {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px dotted transparent;
|
||||
}
|
||||
#figli-home-app .figli-editable-cell:hover {
|
||||
border-bottom-color: var(--figli-text-light);
|
||||
}
|
||||
#figli-home-app .figli-inline-input {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
background: var(--figli-bg);
|
||||
color: var(--figli-text);
|
||||
border: 1px solid var(--figli-border);
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#figli-home-app .figli-note {
|
||||
color: var(--figli-text-light);
|
||||
font-weight: 400;
|
||||
|
||||
@@ -84,3 +84,15 @@ figli_compta_ledger.update_type:
|
||||
parameters:
|
||||
node:
|
||||
type: entity:node
|
||||
|
||||
figli_compta_ledger.update_field:
|
||||
path: '/lignes/{node}/champ'
|
||||
defaults:
|
||||
_controller: '\Drupal\figli_compta_ledger\Controller\LedgerActionsController::updateField'
|
||||
methods: [POST]
|
||||
requirements:
|
||||
_entity_access: 'node.update'
|
||||
options:
|
||||
parameters:
|
||||
node:
|
||||
type: entity:node
|
||||
|
||||
@@ -199,6 +199,22 @@
|
||||
return json;
|
||||
}
|
||||
|
||||
// POST /lignes/{nid}/champ -- change client/facture/libellé without
|
||||
// opening the full edit form. Same fresh-token-per-call reasoning as
|
||||
// updateLigneType() above.
|
||||
async function updateLigneField(nid, field, value) {
|
||||
const tokenRes = await fetch('/session/token');
|
||||
const token = await tokenRes.text();
|
||||
const res = await fetch('/lignes/' + nid + '/champ', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token },
|
||||
body: JSON.stringify({ field, value }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(json.error || ('/lignes/' + nid + '/champ a répondu ' + res.status));
|
||||
return json;
|
||||
}
|
||||
|
||||
async function fetchYearTotals(annee) {
|
||||
const res = await fetch('/lignes/api/totaux?annee=' + encodeURIComponent(annee), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error('/lignes/api/totaux a répondu ' + res.status);
|
||||
@@ -334,6 +350,10 @@
|
||||
// instead of the badge -- only one at a time.
|
||||
editingTypeId: null,
|
||||
typeUpdateError: null,
|
||||
// Which row+field (client/facture/libelle) is currently showing
|
||||
// its inline <input> instead of the plain text -- only one at a
|
||||
// time, mirroring editingTypeId above. { id, field } or null.
|
||||
editingCell: null,
|
||||
// Fenêtre glissante.
|
||||
windowStart: null,
|
||||
windowEnd: null,
|
||||
@@ -777,6 +797,29 @@
|
||||
this.typeUpdateError = err.message;
|
||||
}
|
||||
},
|
||||
startEditCell(item, field) {
|
||||
this.typeUpdateError = null;
|
||||
this.editingCell = { id: item.id, field };
|
||||
},
|
||||
isEditingCell(item, field) {
|
||||
return !!this.editingCell && this.editingCell.id === item.id && this.editingCell.field === field;
|
||||
},
|
||||
// Same optimistic-patch-then-close pattern as saveType() above --
|
||||
// client/facture/libellé don't affect linkability or
|
||||
// field_entree_liee, so there's nothing else to reconcile via
|
||||
// reloadWindow() here.
|
||||
async saveCell(item, field, event) {
|
||||
const newValue = event.target.value.trim();
|
||||
this.editingCell = null;
|
||||
if (newValue === (item[field] || '')) return;
|
||||
try {
|
||||
const result = await updateLigneField(item.nid, field, newValue);
|
||||
const row = this.rows.find((r) => r.id === item.id);
|
||||
if (row) row[field] = result.value;
|
||||
} catch (err) {
|
||||
this.typeUpdateError = err.message;
|
||||
}
|
||||
},
|
||||
onCellHover(evt) {
|
||||
const cell = evt.target.closest('td, th');
|
||||
if (!cell) return;
|
||||
|
||||
@@ -13,11 +13,26 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
* Small write endpoints backing inline (no-modal) edits from the /lignes
|
||||
* table. Each goes through the normal node save() lifecycle -- same as the
|
||||
* full edit form -- so figli_compta_ledger_node_presave() still forces a
|
||||
* proper revision and still enforces the répartition invariant; nothing
|
||||
* here bypasses that.
|
||||
* proper revision. The répartition invariant check is deliberately
|
||||
* skipped for these saves though (same as the type-change endpoint
|
||||
* below): none of client/facture/libellé/type touch montant_ht or
|
||||
* field_repartition, so skipping can never *introduce* a mismatch, only
|
||||
* leave a pre-existing historical one exactly as it was -- see each
|
||||
* method's own comment.
|
||||
*/
|
||||
class LedgerActionsController extends ControllerBase {
|
||||
|
||||
/**
|
||||
* Fields editable inline from /lignes without opening the full node
|
||||
* edit form -- keys are the short names the frontend sends; values are
|
||||
* the real field machine names.
|
||||
*/
|
||||
const INLINE_EDITABLE_FIELDS = [
|
||||
'client' => 'field_client',
|
||||
'facture' => 'field_numero_facture',
|
||||
'libelle' => 'field_notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* Every value field_type_ligne actually allows (see the field's
|
||||
* allowed_values in config) -- validated against here rather than
|
||||
@@ -97,4 +112,76 @@ class LedgerActionsController extends ControllerBase {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /lignes/{node}/champ -- change client/facture/libellé inline,
|
||||
* for clicking directly on those cells in the table. Body:
|
||||
* {"field": "client", "value": "EPAU / POPSU"}. An empty value clears
|
||||
* the field (e.g. a structural charge with no client).
|
||||
*/
|
||||
public function updateField(Request $request, NodeInterface $node) {
|
||||
if ($node->bundle() !== 'ligne_comptable') {
|
||||
return new JsonResponse(['error' => 'Type de contenu invalide.'], 404);
|
||||
}
|
||||
|
||||
$csrfToken = $request->headers->get('X-CSRF-Token', '');
|
||||
if (!\Drupal::csrfToken()->validate($csrfToken, CsrfRequestHeaderAccessCheck::TOKEN_KEY)) {
|
||||
return new JsonResponse(['error' => 'Jeton de sécurité invalide, rechargez la page.'], 403);
|
||||
}
|
||||
|
||||
$data = json_decode($request->getContent(), TRUE);
|
||||
$field = is_array($data) ? ($data['field'] ?? NULL) : NULL;
|
||||
$value = trim((string) (is_array($data) ? ($data['value'] ?? '') : ''));
|
||||
if (!isset(self::INLINE_EDITABLE_FIELDS[$field])) {
|
||||
return new JsonResponse(['error' => 'Champ invalide.'], 400);
|
||||
}
|
||||
$fieldName = self::INLINE_EDITABLE_FIELDS[$field];
|
||||
|
||||
if ($field === 'client') {
|
||||
// Only an existing "Client" term is accepted -- the front-end
|
||||
// offers this as a datalist of known names, not free text, so a
|
||||
// non-match almost certainly means a typo rather than a genuinely
|
||||
// new client that should be created on the fly.
|
||||
if ($value === '') {
|
||||
$node->set('field_client', NULL);
|
||||
}
|
||||
else {
|
||||
$terms = $this->entityTypeManager()->getStorage('taxonomy_term')
|
||||
->loadByProperties(['vid' => 'client', 'name' => $value]);
|
||||
if (!$terms) {
|
||||
return new JsonResponse(['error' => 'Client inconnu : "' . $value . '". Utilisez un nom existant dans la liste.'], 400);
|
||||
}
|
||||
$node->set('field_client', reset($terms)->id());
|
||||
}
|
||||
}
|
||||
else {
|
||||
$node->set($fieldName, $value !== '' ? $value : NULL);
|
||||
}
|
||||
|
||||
// Same reasoning as updateType() above: only client/facture/libellé
|
||||
// changes here, montant_ht and field_repartition are untouched, so
|
||||
// skipping the répartition check for this save can never introduce a
|
||||
// mismatch -- it can only leave a pre-existing historical one exactly
|
||||
// as it was.
|
||||
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
|
||||
try {
|
||||
$node->save();
|
||||
}
|
||||
catch (EntityStorageException $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], 422);
|
||||
}
|
||||
finally {
|
||||
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
|
||||
}
|
||||
|
||||
$newValue = $field === 'client'
|
||||
? ($node->get('field_client')->entity ? $node->get('field_client')->entity->label() : NULL)
|
||||
: $node->get($fieldName)->value;
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => TRUE,
|
||||
'field' => $field,
|
||||
'value' => $newValue,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -144,7 +144,19 @@
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ formatDate(item.date) }}</td>
|
||||
<td>{{ item.client || '—' }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-if="isEditingCell(item, 'client')"
|
||||
type="text"
|
||||
class="figli-inline-input"
|
||||
list="figli-client-datalist"
|
||||
:value="item.client"
|
||||
@change="saveCell(item, 'client', $event)"
|
||||
@blur="editingCell = null"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
/>
|
||||
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'client')">{{ item.client || '—' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
v-if="editingTypeId === item.id"
|
||||
@@ -163,28 +175,50 @@
|
||||
@click="startEditType(item)"
|
||||
>{{ typeLabel(item.type) }}</span>
|
||||
</td>
|
||||
<td>{{ item.facture }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-if="isEditingCell(item, 'facture')"
|
||||
type="text"
|
||||
class="figli-inline-input"
|
||||
:value="item.facture"
|
||||
@change="saveCell(item, 'facture', $event)"
|
||||
@blur="editingCell = null"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
/>
|
||||
<span v-else class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'facture')">{{ item.facture }}</span>
|
||||
</td>
|
||||
<td class="figli-libelle">
|
||||
{{ item.libelle }}
|
||||
<span
|
||||
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
|
||||
class="figli-recon-badge is-clickable"
|
||||
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
|
||||
:title="reconciliationByEntree.get(item.id).detail"
|
||||
@click="toggleEntreeFilter(item.id)"
|
||||
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
|
||||
<span
|
||||
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
|
||||
class="figli-recon-badge is-anomalie"
|
||||
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
|
||||
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
|
||||
<span
|
||||
v-if="linkStatus(item)"
|
||||
class="figli-recon-badge is-clickable"
|
||||
:class="linkStatusClasses(linkStatus(item).kind)"
|
||||
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
|
||||
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0]) : openLinkForm(item.nid)"
|
||||
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item).kind) }}</span>
|
||||
<input
|
||||
v-if="isEditingCell(item, 'libelle')"
|
||||
type="text"
|
||||
class="figli-inline-input"
|
||||
:value="item.libelle"
|
||||
@change="saveCell(item, 'libelle', $event)"
|
||||
@blur="editingCell = null"
|
||||
@keyup.enter="$event.target.blur()"
|
||||
/>
|
||||
<template v-else>
|
||||
<span class="figli-editable-cell" title="Cliquer pour modifier" @click="startEditCell(item, 'libelle')">{{ item.libelle }}</span>
|
||||
<span
|
||||
v-if="item.type === 'entree' && reconciliationByEntree.get(item.id) && reconciliationByEntree.get(item.id).count > 0"
|
||||
class="figli-recon-badge is-clickable"
|
||||
:class="{'is-anomalie': reconciliationByEntree.get(item.id).surVerse > 0, 'is-reste': reconciliationByEntree.get(item.id).resteAVerser > 0}"
|
||||
:title="reconciliationByEntree.get(item.id).detail"
|
||||
@click="toggleEntreeFilter(item.id)"
|
||||
>{{ reconciliationByEntree.get(item.id).count }} sortie{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }} liée{{ reconciliationByEntree.get(item.id).count > 1 ? 's' : '' }}<template v-if="reconciliationByEntree.get(item.id).resteAVerser > 0"> · reste {{ formatEur(reconciliationByEntree.get(item.id).resteAVerser) }}</template><template v-if="reconciliationByEntree.get(item.id).surVerse > 0"> · sur-versé {{ formatEur(reconciliationByEntree.get(item.id).surVerse) }}</template></span>
|
||||
<span
|
||||
v-if="item.type === 'ouverture' && ouvertureEcart(item)"
|
||||
class="figli-recon-badge is-anomalie"
|
||||
:title="'Écart avec la clôture calculée de ' + (item.date.slice(0, 4) - 1) + ' : ' + ouvertureEcart(item).detail"
|
||||
>⚠ écart clôture {{ item.date.slice(0, 4) - 1 }} ({{ ouvertureEcart(item).comptes }} compte{{ ouvertureEcart(item).comptes > 1 ? 's' : '' }})</span>
|
||||
<span
|
||||
v-if="linkStatus(item)"
|
||||
class="figli-recon-badge is-clickable"
|
||||
:class="linkStatusClasses(linkStatus(item).kind)"
|
||||
:title="item.entreeLieeIds.length ? linkStatus(item).detail + ' -- cliquer pour voir la ou les entrées liées' : linkStatus(item).detail + ' -- cliquer pour lier une entrée client'"
|
||||
@click="item.entreeLieeIds.length ? toggleEntreeFilter(item.entreeLieeIds[0]) : openLinkForm(item.nid)"
|
||||
>{{ linkStatus(item).kind === 'ok' ? '' : '⚠ ' }}{{ linkStatusLabel(linkStatus(item).kind) }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="amount" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
|
||||
<td class="amount">{{ formatEur(item.montant_ttc) }}</td>
|
||||
|
||||
Reference in New Issue
Block a user