feat(2.0): dicionário "de → para" por operação + variáveis de autor

Feedback de UX: o admin precisa saber O QUE o Redmine espera, não só os
valores. Cada card do editor ganha a tabela colapsável "Campos que o
Redmine espera" (campos padrão da operação + custom fields do ambiente,
com obrigatoriedade e valores possíveis) — clicar num campo adiciona a
linha com o caminho pronto. A paleta vira explicitamente o "DE" (variáveis
do GLPI) e ganha chamado.tecnico e tarefa.autor (texto; autoria real segue
automática via impersonation). Sync passa a cachear custom_fields de todos
os tipos (issue/time_entry/project).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gemini 2026-07-02 17:00:19 -03:00
parent 1053b9f506
commit 48600b9692
4 changed files with 167 additions and 34 deletions

View file

@ -213,33 +213,16 @@ class PluginRedmineConfig extends CommonDBTM {
];
}
// Referência do ambiente Redmine (ids -> nomes) para consulta na paleta
$palette_ref = [];
$ref_src = [
__('Trackers', 'redmine') => $metadata['trackers'] ?? [],
__('Status', 'redmine') => $metadata['statuses'] ?? [],
__('Prioridades', 'redmine') => $metadata['priorities'] ?? [],
__('Atividades', 'redmine') => $metadata['activities'] ?? [],
];
foreach ($ref_src as $glabel => $list) {
foreach ($list as $el) {
if (isset($el['id'], $el['name'])) {
$palette_ref[$glabel][] = $el['id'] . ' = ' . $el['name'];
}
}
}
foreach (($metadata['project_custom_fields'] ?? []) as $f) {
$vals = array_column($f['possible_values'] ?? [], 'value');
$palette_ref[__('Custom fields (projeto)', 'redmine')][] =
'cf ' . $f['id'] . ' = ' . $f['name']
. (!empty($f['is_required']) ? ' *obrigatório*' : '')
. ($vals ? ' [' . implode(' | ', $vals) . ']' : '');
// Dicionário "PARA" por operação: campos que o Redmine espera/aceita
$field_dicts = [];
foreach (PluginRedmineTemplateengine::OPERATIONS as $op) {
$field_dicts[$op] = PluginRedmineTemplateengine::fieldDictionary($op);
}
\Glpi\Application\View\TemplateRenderer::getInstance()->display('@redmine/config_form.html.twig', [
'templates' => $templates,
'palette_vars' => PluginRedmineTemplateengine::variableCatalog(),
'palette_ref' => $palette_ref,
'field_dicts' => $field_dicts,
'action_url' => Toolbox::getItemTypeFormURL(__CLASS__),
'csrf_token_value' => Session::getNewCSRFToken(),
'redmine_url' => $config['redmine_url'] ?? '',

View file

@ -344,6 +344,15 @@ class PluginRedmineRedmineapi {
return $out;
}
/**
* All custom field definitions (project, issue, time_entry...). Admin only.
* @return array
*/
public static function getAllCustomFields() {
$result = self::get("/custom_fields.json");
return $result['custom_fields'] ?? [];
}
/**
* Ensure a Redmine user is a member of a project (idempotent).
* Impersonation requires the user to be a project member with a role.
@ -418,6 +427,7 @@ class PluginRedmineRedmineapi {
'activities' => self::getActivities(),
'roles' => self::getRoles(),
'project_custom_fields' => self::getProjectCustomFields(),
'custom_fields' => self::getAllCustomFields(),
];
}

View file

@ -172,6 +172,8 @@ class PluginRedmineTemplateengine {
$add('config', 'config.atividade_padrao', 'Atividade padrão (formconfig)');
$add('config', 'config.observadores', 'Observadores padrão (array de ids)');
$add('map', 'map.status[chamado.status]', 'Status Redmine mapeado do status do chamado');
$add('chamado', 'chamado.tecnico', 'Nome do técnico atribuído (texto; a AUTORIA real é automática via impersonation)');
$add('tarefa', 'tarefa.autor', 'Nome de quem lançou a tarefa (texto; a AUTORIA real é automática via impersonation)');
$add('form (criar projeto)', 'form.nome', 'Nome digitado no form');
$add('form (criar projeto)', 'form.identificador | identificador', 'Identificador normalizado');
$add('form (criar projeto)', 'form.descricao', 'Descrição do form');
@ -183,6 +185,100 @@ class PluginRedmineTemplateengine {
return $cat;
}
/**
* Dicionário "PARA" da operação: os campos que o Redmine espera/aceita,
* com obrigatoriedade e valores possíveis do ambiente (via metadata cache).
* Alimenta a tabela "Campos que o Redmine espera" de cada card do editor.
* @return array de ['path','desc','required','values'=>[]]
*/
public static function fieldDictionary($operation) {
global $DB;
$it = $DB->request(['FROM' => 'glpi_plugin_redmine_configs', 'WHERE' => ['id' => 1]]);
$config = count($it) > 0 ? $it->current() : [];
$meta = !empty($config['metadata_cache']) ? json_decode($config['metadata_cache'], true) : [];
$vals = function ($list) {
$out = [];
foreach (($list ?? []) as $e) {
if (isset($e['id'], $e['name'])) {
$out[] = $e['id'] . ' = ' . $e['name'];
}
}
return $out;
};
$trackers = $vals($meta['trackers'] ?? []);
$statuses = $vals($meta['statuses'] ?? []);
$priorities = $vals($meta['priorities'] ?? []);
$activities = $vals($meta['activities'] ?? []);
$D = [];
$add = function ($path, $desc, $req = false, $values = []) use (&$D) {
$D[] = ['path' => $path, 'desc' => $desc, 'required' => $req, 'values' => $values];
};
switch ($operation) {
case 'create_issue':
$add('issue.subject', 'Título da tarefa (máx. 255)', true);
$add('issue.description', 'Descrição');
$add('issue.tracker_id', 'Tipo de tarefa', false, $trackers);
$add('issue.status_id', 'Situação', false, $statuses);
$add('issue.priority_id', 'Prioridade', false, $priorities);
$add('issue.category_id', 'Categoria (os IDs variam por projeto Redmine)');
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
$add('issue.fixed_version_id', 'Versão/marco (por projeto)');
$add('issue.parent_issue_id', 'Tarefa pai (subtarefa)');
$add('issue.estimated_hours', 'Horas estimadas (decimal)');
$add('issue.start_date', 'Data de início (AAAA-MM-DD)');
$add('issue.due_date', 'Data limite (AAAA-MM-DD)');
$add('issue.is_private', 'Privada (true/false)');
$add('issue.watcher_user_ids', 'Observadores (array de IDs)');
break;
case 'log_time':
$add('time_entry.hours', 'Horas (decimal — use {{ tarefa.horas }})', true);
$add('time_entry.comments', 'Comentário (obrigatório em alguns ambientes)');
$add('time_entry.activity_id', 'Atividade (habilitada no projeto de destino!)', false, $activities);
$add('time_entry.spent_on', 'Data do lançamento (AAAA-MM-DD)');
break;
case 'update_status':
$add('issue.status_id', 'Situação', false, $statuses);
$add('issue.notes', 'Comentário adicionado à issue na atualização');
$add('issue.assigned_to_id', 'Atribuído para (ID de usuário Redmine)');
$add('issue.done_ratio', '% concluído (0-100)');
break;
case 'create_project':
$add('project.name', 'Nome do projeto', true);
$add('project.identifier', 'Identificador (minúsculas — use o filtro | identificador)', true);
$add('project.description', 'Descrição');
$add('project.is_public', 'Público (true/false)');
$add('project.parent_id', 'ID do projeto pai (subprojeto de)');
$add('project.inherit_members', 'Herdar membros do pai (true/false)');
$add('project.tracker_ids', 'Tipos de tarefa habilitados (array)', false, $trackers);
$add('project.enabled_module_names', 'Módulos (array: issue_tracking, time_tracking, wiki...)');
break;
}
// Custom fields do ambiente, por tipo de objeto da operação
$type_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
$root_by_op = ['create_project' => 'project', 'create_issue' => 'issue', 'log_time' => 'time_entry'];
if (isset($type_by_op[$operation])) {
$cfs = $meta['custom_fields'] ?? $meta['project_custom_fields'] ?? [];
foreach ($cfs as $f) {
if (($f['customized_type'] ?? '') !== $type_by_op[$operation]) {
continue;
}
$pv = array_column($f['possible_values'] ?? [], 'value');
$add(
$root_by_op[$operation] . '.custom_field_values.' . (int) $f['id'],
$f['name'] . ' (campo personalizado do seu Redmine)',
!empty($f['is_required']),
$pv
);
}
}
return $D;
}
// ------------------------------------------------------------------
// Contexto
// ------------------------------------------------------------------
@ -241,12 +337,24 @@ class PluginRedmineTemplateengine {
];
if ($ticket !== null) {
global $DB;
$tecnico = null;
$itT = $DB->request([
'SELECT' => 'users_id',
'FROM' => 'glpi_tickets_users',
'WHERE' => ['tickets_id' => (int) $ticket->getID(), 'type' => \CommonITILActor::ASSIGN, ['users_id' => ['>', 0]]],
'LIMIT' => 1,
]);
if (count($itT) > 0) {
$tecnico = getUserName($itT->current()['users_id']);
}
$ctx['chamado'] = [
'id' => (int) $ticket->getID(),
'titulo' => (string) $ticket->fields['name'],
'descricao' => (string) $ticket->fields['content'],
'status' => (int) $ticket->fields['status'],
'entidade' => (int) $ticket->fields['entities_id'],
'tecnico' => $tecnico,
];
}
@ -261,6 +369,7 @@ class PluginRedmineTemplateengine {
'comentario' => ($desc !== '') ? $desc
: 'Tempo lançado via GLPI (chamado #' . (int) $task->fields['tickets_id'] . ')',
'data' => substr((string) ($task->fields['date'] ?? ''), 0, 10) ?: null,
'autor' => !empty($task->fields['users_id']) ? getUserName($task->fields['users_id']) : null,
];
}

View file

@ -157,8 +157,11 @@
{# ---- Paleta ---- #}
<div class="col-lg-4">
<div class="card">
<div class="card-header py-2"><strong>{{ __('Variáveis (clique para copiar)', 'redmine') }}</strong></div>
<div class="card-body p-2" style="max-height: 420px; overflow-y: auto;">
<div class="card-header py-2">
<strong>{{ __('Variáveis do GLPI — o "DE"', 'redmine') }}</strong>
<div class="text-muted" style="font-size:.7rem">{{ __('Clique para copiar a tag. Os campos que o Redmine espera (o "PARA") estão em cada operação, à direita.', 'redmine') }}</div>
</div>
<div class="card-body p-2" style="max-height: 480px; overflow-y: auto;">
{% set lastgroup = '' %}
{% for v in palette_vars %}
{% if v.group != lastgroup %}
@ -170,16 +173,6 @@
<i class="ti ti-copy text-muted" style="font-size:.7rem"></i>
</div>
{% endfor %}
{% if palette_ref is not empty %}
<hr class="my-2">
<div class="text-uppercase text-muted mb-1" style="font-size:.7rem">{{ __('Referência do seu Redmine', 'redmine') }}</div>
{% for glabel, items in palette_ref %}
<div class="mt-1 mb-1"><strong style="font-size:.75rem">{{ glabel }}</strong></div>
{% for item in items %}
<div style="font-size:.72rem" class="text-muted">{{ item }}</div>
{% endfor %}
{% endfor %}
{% endif %}
</div>
</div>
</div>
@ -209,6 +202,33 @@
</button>
</span>
</div>
{% if field_dicts[op] is defined and field_dicts[op] is not empty %}
<div class="px-3 py-1 border-bottom">
<a class="small" data-bs-toggle="collapse" href="#rmdict-{{ op }}" role="button">
<i class="ti ti-book me-1"></i>{{ __('Campos que o Redmine espera — o "PARA" (clique num campo para adicioná-lo)', 'redmine') }}
</a>
<div class="collapse" id="rmdict-{{ op }}">
<table class="table table-sm table-hover mb-1 mt-1" style="font-size:.75rem">
<tbody>
{% for f in field_dicts[op] %}
<tr class="rm-dict-row" data-path="{{ f.path }}" title="{{ __('Clique para adicionar este campo ao template', 'redmine') }}">
<td style="white-space:nowrap">
<code>{{ f.path }}</code>
{% if f.required %}<span class="badge bg-red-lt text-red ms-1">{{ __('obrigatório', 'redmine') }}</span>{% endif %}
</td>
<td>
{{ f.desc }}
{% if f.values is not empty %}
<div class="text-muted" style="font-size:.7rem">{{ f.values|join(' · ') }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<div class="card-body py-2">
<div class="rm-tpl-lines"></div>
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-add mt-1">
@ -353,6 +373,17 @@
setMode();
});
addBtn.addEventListener('click', function () { addRow('', ''); });
// Dicionário "PARA": clique num campo adiciona a linha com o caminho pronto
card.querySelectorAll('.rm-dict-row').forEach(function (r) {
r.style.cursor = 'pointer';
r.addEventListener('click', function () {
if (rawMode) { alert('Volte para "Ver campos" para adicionar pela lista.'); return; }
addRow(r.dataset.path, '');
var rows = linesBox.querySelectorAll('.rm-tpl-row');
var last = rows[rows.length - 1];
if (last) { last.querySelector('.rm-v').focus(); }
});
});
form.addEventListener('submit', function () { syncToTextarea(); });
renderLines();
setMode();