First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
153 lines
5.3 KiB
PHP
153 lines
5.3 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Admin;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Ldap\User;
|
|
use App\Services\LdapAttributeHelper;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\View\View;
|
|
|
|
class OrgChartController extends Controller
|
|
{
|
|
/**
|
|
* Graficzna struktura organizacyjna z relacji manager -> directReports.
|
|
*
|
|
* Do wykresu trafiają WYŁĄCZNIE aktywne konta, które faktycznie są w
|
|
* hierarchii: mają przypisanego przełożonego LUB są czyimś przełożonym.
|
|
* Konta wyłączone oraz konta bez żadnej relacji są pomijane całkowicie.
|
|
*
|
|
* Cały zbiór pobieramy jednym zapytaniem (paged control) i budujemy drzewo
|
|
* w PHP. Wynik w cache pliku na 10 minut ("Przelicz" czyści cache).
|
|
*/
|
|
public function index(): View
|
|
{
|
|
$data = Cache::store('file')->remember('org_chart:v3', 600, fn () => $this->build());
|
|
|
|
return view('admin.org.index', [
|
|
'flat' => $data['flat'],
|
|
'stats' => $data['stats'],
|
|
'refreshed_at' => Cache::store('file')->get('org_chart:v3_ts', now()->format('Y-m-d H:i')),
|
|
]);
|
|
}
|
|
|
|
public function refresh()
|
|
{
|
|
Cache::store('file')->forget('org_chart:v3');
|
|
Cache::store('file')->forget('org_chart:v3_ts');
|
|
|
|
return redirect()->route('org.index');
|
|
}
|
|
|
|
/**
|
|
* Buduje PŁASKĄ listę węzłów (id / parentId) dla biblioteki d3-org-chart.
|
|
* Jeden syntetyczny korzeń "__root__" spina osoby bez przełożonego.
|
|
*
|
|
* @return array{flat: list<array<string,mixed>>, stats: array<string,int>}
|
|
*/
|
|
protected function build(): array
|
|
{
|
|
$users = User::query()
|
|
->in(config('ldap.defaults.user_ou'))->recursive()
|
|
->select(['cn', 'samaccountname', 'title', 'department', 'manager', 'directReports', 'userAccountControl', 'distinguishedName'])
|
|
->orderBy('cn')
|
|
->paginate(500);
|
|
|
|
// Węzły kwalifikujące się do wykresu: aktywne + faktycznie w hierarchii.
|
|
$byDn = []; // dn(lower) => sam
|
|
$nodes = []; // sam => dane węzła
|
|
foreach ($users as $u) {
|
|
$uac = (int) ($u->getFirstAttribute('useraccountcontrol') ?? 0);
|
|
if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
|
|
continue; // tylko aktywni
|
|
}
|
|
|
|
$managerDn = trim((string) $u->getFirstAttribute('manager'));
|
|
$reports = array_filter(array_map('trim', $u->getAttribute('directreports') ?? []));
|
|
|
|
if ($managerDn === '' && count($reports) === 0) {
|
|
continue; // poza hierarchią - nie pokazujemy wcale
|
|
}
|
|
|
|
$sam = $u->getFirstAttribute('samaccountname');
|
|
$byDn[strtolower(trim((string) $u->getDn()))] = $sam;
|
|
$nodes[$sam] = [
|
|
'sam' => $sam,
|
|
'cn' => $u->getFirstAttribute('cn') ?: $sam,
|
|
'title' => $u->getFirstAttribute('title'),
|
|
'department' => $u->getFirstAttribute('department'),
|
|
'manager_dn' => $managerDn !== '' ? strtolower($managerDn) : null,
|
|
];
|
|
}
|
|
|
|
// parentId = sam przełożonego, jeśli jest w zbiorze; inaczej korzeń.
|
|
$parentOf = [];
|
|
foreach ($nodes as $sam => $n) {
|
|
$pdn = $n['manager_dn'];
|
|
$parentOf[$sam] = ($pdn && isset($byDn[$pdn]) && $byDn[$pdn] !== $sam)
|
|
? $byDn[$pdn]
|
|
: '__root__';
|
|
}
|
|
|
|
// Rozbijanie cykli manager -> ... -> ten sam (d3-org-chart tego nie zniesie).
|
|
foreach (array_keys($parentOf) as $sam) {
|
|
$seen = [$sam => true];
|
|
$cur = $parentOf[$sam];
|
|
$guard = 0;
|
|
while ($cur !== '__root__' && $guard++ < 100) {
|
|
if (isset($seen[$cur])) {
|
|
$parentOf[$sam] = '__root__';
|
|
break;
|
|
}
|
|
$seen[$cur] = true;
|
|
$cur = $parentOf[$cur] ?? '__root__';
|
|
}
|
|
}
|
|
|
|
$childCount = array_count_values(array_values($parentOf)); // parent sam => liczba dzieci
|
|
|
|
$flat = [[
|
|
'id' => '__root__',
|
|
'parentId' => '',
|
|
'name' => config('ldap.defaults.domain_label'),
|
|
'title' => __('org.top_level'),
|
|
'department' => null,
|
|
'sam' => null,
|
|
'root' => true,
|
|
]];
|
|
|
|
$cmp = fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']);
|
|
$people = [];
|
|
foreach ($nodes as $sam => $n) {
|
|
$people[] = [
|
|
'id' => $sam,
|
|
'parentId' => $parentOf[$sam],
|
|
'name' => $n['cn'],
|
|
'title' => $n['title'],
|
|
'department' => $n['department'],
|
|
'sam' => $sam,
|
|
];
|
|
}
|
|
usort($people, $cmp);
|
|
$flat = array_merge($flat, $people);
|
|
|
|
$managers = 0;
|
|
foreach ($childCount as $parentSam => $count) {
|
|
if ($parentSam !== '__root__' && $count > 0) {
|
|
$managers++;
|
|
}
|
|
}
|
|
|
|
Cache::store('file')->put('org_chart:v3_ts', now()->format('Y-m-d H:i'), 600);
|
|
|
|
return [
|
|
'flat' => $flat,
|
|
'stats' => [
|
|
'people' => count($nodes),
|
|
'managers' => $managers,
|
|
'top' => $childCount['__root__'] ?? 0,
|
|
],
|
|
];
|
|
}
|
|
}
|