Feedback de UX: com o Gerar template automático, a coluna ficava ociosa. As variáveis do GLPI agora aparecem como autocomplete (datalist) direto no campo de valor de cada linha, + um painel de referência colapsado (fechado por padrão) para descoberta em remapeamentos. Editores em largura total. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
396 lines
21 KiB
Twig
396 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>
|
|
|
|
{# Variáveis do GLPI como AUTOCOMPLETE nos campos de valor #}
|
|
<datalist id="rm-glpi-vars">
|
|
{% for v in palette_vars %}
|
|
<option value="{{ v.tag }}">{{ v.group }} — {{ v.desc }}</option>
|
|
{% endfor %}
|
|
</datalist>
|
|
|
|
{# Referência opcional (colapsada) — descoberta das tags para remapeamentos #}
|
|
<div class="mb-3">
|
|
<a class="small" data-bs-toggle="collapse" href="#rm-glpi-vars-panel" role="button">
|
|
<i class="ti ti-variable me-1"></i>{{ __('Variáveis do GLPI disponíveis — o "DE" (referência; nos campos de valor há autocomplete)', 'redmine') }}
|
|
</a>
|
|
<div class="collapse" id="rm-glpi-vars-panel">
|
|
<div class="card card-body p-2 mt-1" style="max-height:300px;overflow-y:auto;columns:2">
|
|
{% set lastgroup = '' %}
|
|
{% for v in palette_vars %}
|
|
{% if v.group != lastgroup %}
|
|
{% set lastgroup = v.group %}
|
|
<div class="mt-1 mb-1 text-uppercase text-muted" style="font-size:.7rem;break-inside:avoid">{{ v.group }}</div>
|
|
{% endif %}
|
|
<div class="rm-var px-1 rounded" data-tag="{{ v.tag }}" title="{{ v.desc }}" style="break-inside:avoid">
|
|
<code style="font-size:.75rem">{{ v.tag }}</code>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Editores por operação (largura total) ---- #}
|
|
<div>
|
|
{% 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>
|
|
|
|
{% 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
}
|
|
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" list="rm-glpi-vars" style="font-family:monospace;font-size:.78rem" placeholder="valor fixo ou variável do GLPI (autocomplete)" value="' + esc(v) + '">' +
|
|
'<button type="button" class="btn btn-sm btn-outline-danger rm-del">×</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 %}
|