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:
parent
1053b9f506
commit
48600b9692
4 changed files with 167 additions and 34 deletions
|
|
@ -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'] ?? '',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue