Redmine/templates/config_form.html.twig
Gemini 48600b9692 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>
2026-07-02 17:00:19 -03:00

393 lines
21 KiB
Twig

{% import 'components/form/fields_macros.html.twig' as fields %}
<form method="post" action="{{ action_url }}">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="id" value="1">
{# --- Conexão --- #}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="card-title mb-0">
<i class="ti ti-plug-connected me-1"></i>{{ __('Conexão com o Redmine', 'redmine') }}
</h4>
<button type="submit" name="sync" value="1" class="btn btn-sm btn-outline-secondary">
<i class="ti ti-refresh me-1"></i>{{ __('Sincronizar Metadados', 'redmine') }}
</button>
</div>
<div class="card-body">
<div class="row">
{{ fields.textField('redmine_url', redmine_url, __('URL do Redmine', 'redmine'), {
'helper': 'https://redmine.exemplo.com.br'
}) }}
{{ fields.passwordField('api_key', '', __('Chave de API (Admin)', 'redmine'), {
'helper': has_key
? __('Deixe em branco para manter a chave atual', 'redmine')
: __('Chave de API de um usuário administrador do Redmine', 'redmine')
}) }}
</div>
</div>
</div>
{# --- Valores padrão da issue --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-settings me-1"></i>{{ __('Valores padrão da tarefa (issue) Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if not has_metadata %}
<div class="alert alert-warning">{{ __('Salve a URL e a chave e clique em "Sincronizar Metadados" para carregar as opções.', 'redmine') }}</div>
{% endif %}
<div class="row">
{{ fields.dropdownArrayField('default_tracker_id', default_tracker_id, tracker_options, __('Tipo (Tracker) padrão', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_priority_id', default_priority_id, priority_options, __('Prioridade padrão', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_activity_id', default_activity_id, activity_options, __('Atividade padrão (lançamento de tempo)', 'redmine'), {'display_emptychoice': true}) }}
</div>
</div>
</div>
{# --- Autores e observadores --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-users me-1"></i>{{ __('Autores e observadores', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
{{ __('O autor (técnico atribuído) é adicionado automaticamente como membro do projeto Redmine com o papel abaixo, para permitir a impersonation.', 'redmine') }}
</div>
<div class="row">
{{ fields.dropdownArrayField('default_role_id', default_role_id, role_options, __('Papel padrão para membros (auto-membership)', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('default_watcher_ids', '', redmine_user_options, __('Observadores padrão', 'redmine'), {
'multiple': true,
'values': default_watcher_ids
}) }}
</div>
</div>
</div>
{# --- Mapa de status --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-arrows-exchange me-1"></i>{{ __('Mapa de status: Chamado GLPI → Issue Redmine', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="row">
{% for sid, slabel in glpi_ticket_statuses %}
{{ fields.dropdownArrayField('status_map[' ~ sid ~ ']', attribute(status_map, sid)|default(''), status_options, slabel, {'display_emptychoice': true}) }}
{% endfor %}
</div>
</div>
</div>
<div class="d-flex justify-content-end mb-3">
<button type="submit" name="update" value="1" class="btn btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
</button>
</div>
</form>
{# --- Mapeamento de usuários (overrides) — fora do form principal --- #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-user-cog me-1"></i>{{ __('Mapeamento de usuários (GLPI → Redmine)', 'redmine') }}</h4>
</div>
<div class="card-body">
{% if user_cache_count|default(0) == 0 %}
<div class="alert alert-warning">
<i class="ti ti-alert-triangle me-1"></i>
{{ __('Cache de usuários Redmine VAZIO — os lançamentos sairão como admin. Clique em "Sincronizar Metadados".', 'redmine') }}
</div>
{% endif %}
<div class="alert alert-info">
{{ __('Usuários com o mesmo e-mail nos dois sistemas são associados automaticamente. Use a tabela abaixo apenas para exceções (e-mails diferentes).', 'redmine') }}
<br><small>{{ __('Usuários Redmine em cache:', 'redmine') }} <strong>{{ user_cache_count|default(0) }}</strong></small>
</div>
<table class="table table-sm align-middle">
<thead><tr>
<th>{{ __('Usuário GLPI', 'redmine') }}</th>
<th>{{ __('Usuário Redmine', 'redmine') }}</th>
<th class="text-end">{{ _x('button', 'Delete') }}</th>
</tr></thead>
<tbody>
{% for m in usermap %}
<tr>
<td>{{ m.glpi_name }}</td>
<td>{{ m.redmine_name }}</td>
<td class="text-end">
<form method="post" action="{{ action_url }}" class="d-inline">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="del_usermap" value="{{ m.id }}">
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="ti ti-trash"></i></button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="text-muted">{{ __('Nenhum override cadastrado.', 'redmine') }}</td></tr>
{% endfor %}
</tbody>
</table>
<form method="post" action="{{ action_url }}" class="mt-3">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="add_usermap" value="1">
<div class="row">
{{ fields.dropdownField('User', 'override_users_id', 0, __('Usuário GLPI', 'redmine'), {'display_emptychoice': true}) }}
{{ fields.dropdownArrayField('override_redmine_user_id', '', redmine_user_options, __('Usuário Redmine', 'redmine'), {'display_emptychoice': true}) }}
</div>
<div class="text-end">
<button type="submit" class="btn btn-outline-primary"><i class="ti ti-plus me-1"></i>{{ _x('button', 'Add') }}</button>
</div>
</form>
</div>
</div>
{# ====================================================================
Templates de Payload (motor 2.0) — editor linha-a-linha + paleta
==================================================================== #}
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title mb-0"><i class="ti ti-code me-1"></i>{{ __('Templates de Payload (avançado)', 'redmine') }}</h4>
</div>
<div class="card-body">
<div class="alert alert-info">
{{ __('Personalize o JSON enviado ao Redmine em cada operação. Sem template ativo, o plugin usa o comportamento padrão. "Gerar template" cria o ponto de partida equivalente ao padrão + campos obrigatórios descobertos no seu Redmine.', 'redmine') }}
</div>
<div class="row">
{# ---- Paleta ---- #}
<div class="col-lg-4">
<div class="card">
<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 %}
{% set lastgroup = v.group %}
<div class="mt-2 mb-1 text-uppercase text-muted" style="font-size: .7rem;">{{ v.group }}</div>
{% endif %}
<div class="rm-var d-flex justify-content-between align-items-center px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}">
<code style="font-size:.75rem">{{ v.tag }}</code>
<i class="ti ti-copy text-muted" style="font-size:.7rem"></i>
</div>
{% endfor %}
</div>
</div>
</div>
{# ---- Editores por operação ---- #}
<div class="col-lg-8">
{% for op, t in templates %}
<form method="post" action="{{ action_url }}">
<input type="hidden" name="_glpi_csrf_token" value="{{ csrf_token_value }}">
<input type="hidden" name="template_op" value="{{ op }}">
<div class="card mb-3 rm-tpl-card">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<span>
<strong>{{ t.label }}</strong>
<code class="ms-1" style="font-size:.7rem">{{ op }}</code>
{% if t.is_active %}
<span class="badge bg-green-lt text-green ms-1">{{ __('ativo', 'redmine') }}</span>
{% else %}
<span class="badge bg-secondary-lt ms-1">{{ __('inativo (usa o padrão)', 'redmine') }}</span>
{% endif %}
</span>
<span>
<button type="button" class="btn btn-sm btn-outline-secondary rm-tpl-toggle">{{ __('Ver JSON', 'redmine') }}</button>
<button type="submit" name="gen_template" value="1" class="btn btn-sm btn-outline-primary"
onclick="return confirm('{{ __('Gerar/regravar o template desta operação a partir do padrão? O conteúdo atual será substituído (fica inativo até você ativar).', 'redmine') }}');">
<i class="ti ti-wand me-1"></i>{{ __('Gerar template', 'redmine') }}
</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">
<i class="ti ti-plus me-1"></i>{{ __('Adicionar campo', 'redmine') }}
</button>
<textarea name="template_json" class="form-control rm-tpl-json d-none mt-1" rows="10"
spellcheck="false" style="font-family: monospace; font-size:.8rem">{{ t.template }}</textarea>
</div>
<div class="card-footer py-2 d-flex justify-content-between align-items-center">
<label class="form-check m-0">
<input type="checkbox" class="form-check-input" name="template_active" value="1" {{ t.is_active ? 'checked' : '' }}>
<span class="form-check-label">{{ __('Ativo', 'redmine') }}</span>
</label>
<button type="submit" name="save_template" value="1" class="btn btn-sm btn-primary">
<i class="ti ti-device-floppy me-1"></i>{{ _x('button', 'Save') }}
</button>
</div>
</div>
</form>
{% endfor %}
</div>
</div>
</div>
</div>
{% verbatim %}
<script>
(function () {
// ---- Paleta: clique copia a tag ----
document.querySelectorAll('.rm-var').forEach(function (el) {
el.style.cursor = 'pointer';
el.addEventListener('click', function () {
var tag = el.dataset.tag;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(tag);
} else {
window.prompt('Copie a tag:', tag);
}
el.classList.add('bg-green-lt');
setTimeout(function () { el.classList.remove('bg-green-lt'); }, 500);
});
});
// ---- Editor linha-a-linha <-> JSON ----
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function flatten(obj, prefix, out) {
for (var k in obj) {
if (!Object.prototype.hasOwnProperty.call(obj, k)) continue;
var v = obj[k];
var p = prefix ? prefix + '.' + k : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
flatten(v, p, out);
} else {
out.push([p, (typeof v === 'string') ? v : JSON.stringify(v)]);
}
}
return out;
}
function parseVal(s) {
s = String(s).trim();
if (s === '') return '';
try {
var v = JSON.parse(s);
if (typeof v === 'number' || typeof v === 'boolean' || Array.isArray(v) || v === null) return v;
} catch (e) { /* string */ }
return s;
}
function unflatten(rows) {
var root = {};
rows.forEach(function (r) {
var p = r[0], val = r[1];
if (!p) return;
var parts = p.split('.');
var cur = root;
parts.forEach(function (seg, i) {
if (i === parts.length - 1) {
cur[seg] = parseVal(val);
} else {
if (typeof cur[seg] !== 'object' || cur[seg] === null || Array.isArray(cur[seg])) cur[seg] = {};
cur = cur[seg];
}
});
});
return root;
}
document.querySelectorAll('.rm-tpl-card').forEach(function (card) {
var ta = card.querySelector('.rm-tpl-json');
var linesBox = card.querySelector('.rm-tpl-lines');
var toggle = card.querySelector('.rm-tpl-toggle');
var addBtn = card.querySelector('.rm-tpl-add');
var form = card.closest('form');
var rawMode = false;
function addRow(p, v) {
var row = document.createElement('div');
row.className = 'd-flex gap-2 mb-1 rm-tpl-row';
row.innerHTML =
'<input class="form-control form-control-sm rm-k" style="max-width:42%;font-family:monospace;font-size:.78rem" placeholder="caminho.do.campo" value="' + esc(p) + '">' +
'<input class="form-control form-control-sm rm-v" style="font-family:monospace;font-size:.78rem" placeholder="valor fixo ou tag da paleta" value="' + esc(v) + '">' +
'<button type="button" class="btn btn-sm btn-outline-danger rm-del">&times;</button>';
row.querySelector('.rm-del').addEventListener('click', function () { row.remove(); });
linesBox.appendChild(row);
}
function renderLines() {
linesBox.innerHTML = '';
var data = {};
var raw = ta.value.trim();
if (raw !== '') {
try { data = JSON.parse(raw); } catch (e) { rawMode = true; setMode(); return; }
}
flatten(data, '', []).forEach(function (r) { addRow(r[0], r[1]); });
if (!linesBox.children.length) addRow('', '');
}
function syncToTextarea() {
if (rawMode) return;
var rows = Array.prototype.map.call(linesBox.querySelectorAll('.rm-tpl-row'), function (r) {
return [r.querySelector('.rm-k').value.trim(), r.querySelector('.rm-v').value];
}).filter(function (r) { return r[0] !== ''; });
ta.value = rows.length ? JSON.stringify(unflatten(rows), null, 2) : '';
}
function setMode() {
ta.classList.toggle('d-none', !rawMode);
linesBox.classList.toggle('d-none', rawMode);
addBtn.classList.toggle('d-none', rawMode);
toggle.textContent = rawMode ? 'Ver campos' : 'Ver JSON';
}
toggle.addEventListener('click', function () {
if (rawMode) {
var raw = ta.value.trim();
if (raw !== '') {
try { JSON.parse(raw); } catch (e) { alert('JSON inválido — corrija antes de voltar para os campos.'); return; }
}
rawMode = false;
renderLines();
} else {
syncToTextarea();
rawMode = true;
}
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();
});
})();
</script>
{% endverbatim %}