Organizasyon Şeması komponenti

This commit is contained in:
Sedat Öztürk 2026-05-04 22:50:21 +03:00
parent 31f632d16a
commit 4444fce93b
8 changed files with 1140 additions and 8 deletions

View file

@ -75,9 +75,7 @@ public static class LookupQueryValues
$"ON \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"Key\" = \"{FullNameTable(TableNameEnum.LanguageText)}\".\"Key\" " +
$"AND \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"ResourceName\" = \"{FullNameTable(TableNameEnum.LanguageText)}\".\"ResourceName\" " +
$"WHERE " +
$"\"{FullNameTable(TableNameEnum.LanguageKey)}\".\"IsDeleted\" = 'false' " +
$"AND \"{FullNameTable(TableNameEnum.LanguageText)}\".\"IsDeleted\" = 'false' " +
$"AND \"{FullNameTable(TableNameEnum.LanguageText)}\".\"CultureName\" = 'tr' " +
$"\"{FullNameTable(TableNameEnum.LanguageText)}\".\"CultureName\" = 'tr' " +
$"ORDER BY \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"Key\";";
public static string CountryValues =

View file

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace Sozsoft.Platform.OrgChart;
public class OrgChartUserDto
{
public Guid Id { get; set; }
public string FullName { get; set; }
public string Email { get; set; }
public string UserName { get; set; }
}
public class OrgChartNodeDto
{
public Guid Id { get; set; }
public string Name { get; set; }
public Guid? ParentId { get; set; }
// Only for JobPosition nodes
public Guid? DepartmentId { get; set; }
public string DepartmentName { get; set; }
public List<OrgChartUserDto> Users { get; set; } = [];
}

View file

@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sozsoft.Platform.Entities;
using Sozsoft.Platform.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
namespace Sozsoft.Platform.OrgChart;
[Authorize]
[Route("api/app/org-chart")]
public class OrgChartAppService : PlatformAppService
{
private readonly IRepository<Department, Guid> _departmentRepository;
private readonly IRepository<JobPosition, Guid> _jobPositionRepository;
private readonly IIdentityUserRepository _userRepository;
public OrgChartAppService(
IRepository<Department, Guid> departmentRepository,
IRepository<JobPosition, Guid> jobPositionRepository,
IIdentityUserRepository userRepository)
{
_departmentRepository = departmentRepository;
_jobPositionRepository = jobPositionRepository;
_userRepository = userRepository;
}
[HttpGet("departments")]
public async Task<List<OrgChartNodeDto>> GetDepartmentsAsync()
{
var departments = await _departmentRepository.GetListAsync();
var jobPositions = await _jobPositionRepository.GetListAsync();
var users = await _userRepository.GetListAsync();
var jobById = jobPositions.ToDictionary(x => x.Id);
var topPositionByDepartment = new Dictionary<Guid, JobPosition>();
foreach (var department in departments)
{
var departmentPositions = jobPositions
.Where(x => x.DepartmentId == department.Id)
.ToList();
if (!departmentPositions.Any())
{
continue;
}
// Top position means: no parent, parent missing, or parent belongs to another department.
var topCandidates = departmentPositions
.Where(position =>
!position.ParentId.HasValue ||
!jobById.ContainsKey(position.ParentId.Value) ||
jobById[position.ParentId.Value].DepartmentId != department.Id)
.OrderBy(position => position.Name)
.ToList();
var selectedTop = topCandidates.FirstOrDefault() ?? departmentPositions
.OrderBy(position => position.Name)
.First();
topPositionByDepartment[department.Id] = selectedTop;
}
var nodes = departments.Select(d => new OrgChartNodeDto
{
Id = d.Id,
Name = d.Name,
ParentId = d.ParentId,
Users = users
.Where(u =>
topPositionByDepartment.TryGetValue(d.Id, out var topPosition) &&
u.GetJobPositionId() == topPosition.Id)
.Select(u => new OrgChartUserDto
{
Id = u.Id,
FullName = u.GetFullName(),
Email = u.Email,
UserName = u.UserName,
})
.ToList(),
}).ToList();
return nodes;
}
[HttpGet("job-positions")]
public async Task<List<OrgChartNodeDto>> GetJobPositionsAsync()
{
var jobPositions = await _jobPositionRepository.GetListAsync();
var departments = await _departmentRepository.GetListAsync();
var users = await _userRepository.GetListAsync();
var deptDict = departments.ToDictionary(d => d.Id, d => d.Name);
var nodes = jobPositions.Select(jp => new OrgChartNodeDto
{
Id = jp.Id,
Name = jp.Name,
ParentId = jp.ParentId,
DepartmentId = jp.DepartmentId,
DepartmentName = deptDict.TryGetValue(jp.DepartmentId, out var name) ? name : null,
Users = users
.Where(u => u.GetJobPositionId() == jp.Id)
.Select(u => new OrgChartUserDto
{
Id = u.Id,
FullName = u.GetFullName(),
Email = u.Email,
UserName = u.UserName,
})
.ToList(),
}).ToList();
return nodes;
}
}

View file

@ -948,6 +948,18 @@
"en": "No record found...",
"tr": "Kayıt Bulunamadı..."
},
{
"resourceName": "Platform",
"key": "App.Definitions.OrgChart",
"en": "Organization Chart",
"tr": "Organizasyon Şeması"
},
{
"resourceName": "Platform",
"key": "App.Definitions.OrgChart.ShowUsers",
"en": "Show Users",
"tr": "Kullanıcıları Göster"
},
{
"resourceName": "Platform",
"key": "App.Definitions.GlobalSearch",

View file

@ -245,6 +245,13 @@
"routeType": "protected",
"authority": ["Abp.Identity.OrganizationUnits"]
},
{
"key": "admin.hr.organization",
"path": "/admin/organization",
"componentPath": "@/views/admin/hr/OrgChart",
"routeType": "protected",
"authority": ["App.Definitions.Department"]
},
{
"key": "admin.forum",
"path": "/admin/forum",
@ -964,6 +971,16 @@
"RequiredPermissionName": "App.Definitions.JobPosition",
"IsDisabled": false
},
{
"ParentCode": "App.Administration.Definitions",
"Code": "App.Definitions.OrgChart",
"DisplayName": "App.Definitions.OrgChart",
"Order": 4,
"Url": "/admin/organization",
"Icon": "FaSitemap",
"RequiredPermissionName": "App.Definitions.OrgChart",
"IsDisabled": false
},
{
"ParentCode": "App.Administration",
"Code": "App.Administration.Restrictions",

View file

@ -1603,7 +1603,6 @@
"MultiTenancySide": 2,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Saas",
"Name": "App.Orders.SalesOrderItem",
@ -2522,7 +2521,6 @@
"MultiTenancySide": 3,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Administration",
"Name": "App.Definitions.Department",
@ -2577,7 +2575,6 @@
"MultiTenancySide": 3,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Administration",
"Name": "App.Definitions.JobPosition",
@ -2632,7 +2629,15 @@
"MultiTenancySide": 3,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Administration",
"Name": "App.Definitions.OrgChart",
"ParentName": null,
"DisplayName": "App.Definitions.OrgChart",
"IsEnabled": true,
"MultiTenancySide": 3,
"MenuGroup": "Erp|Kurs"
},
{
"GroupName": "App.Administration",
"Name": "App.Restrictions.WorkHour",

View file

@ -0,0 +1,29 @@
import apiService from './api.service'
export interface OrgChartUserDto {
id: string
fullName: string
email: string
userName: string
}
export interface OrgChartNodeDto {
id: string
name: string
parentId?: string
departmentId?: string
departmentName?: string
users: OrgChartUserDto[]
}
export const getOrgChartDepartments = () =>
apiService.fetchData<OrgChartNodeDto[]>({
method: 'GET',
url: '/api/app/org-chart/departments',
})
export const getOrgChartJobPositions = () =>
apiService.fetchData<OrgChartNodeDto[]>({
method: 'GET',
url: '/api/app/org-chart/job-positions',
})

View file

@ -0,0 +1,925 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
FaSitemap,
FaBriefcase,
FaBuilding,
FaChevronDown,
FaChevronRight,
FaSearchPlus,
FaSearchMinus,
FaUndo,
FaFileImage,
} from 'react-icons/fa'
import { Helmet } from 'react-helmet'
import {
getOrgChartDepartments,
getOrgChartJobPositions,
OrgChartNodeDto,
OrgChartUserDto,
} from '@/services/orgChart.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { APP_NAME } from '@/constants/app.constant'
import Loading from '@/components/shared/Loading'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Avatar } from '@/components/ui'
import { AVATAR_URL } from '@/constants/app.constant'
import { useStoreState } from '@/store'
type ViewMode = 'department' | 'jobPosition'
// ---- JPG Export helpers ----
// Tailwind class → hex (keep in sync with LEVEL_COLORS below)
const TW_HEX: Record<string, string> = {
'bg-blue-500': '#3b82f6',
'bg-indigo-500': '#6366f1',
'bg-cyan-500': '#06b6d4',
'bg-teal-500': '#14b8a6',
'bg-violet-500': '#8b5cf6',
'bg-sky-500': '#0ea5e9',
'bg-slate-500': '#64748b',
'border-blue-200': '#bfdbfe',
'border-indigo-200': '#c7d2fe',
'border-cyan-200': '#a5f3fc',
'border-teal-200': '#99f6e4',
'border-violet-200': '#ddd6fe',
'border-sky-200': '#bae6fd',
'border-slate-200': '#e2e8f0',
}
function loadImg(src: string): Promise<HTMLImageElement | null> {
return new Promise((resolve) => {
if (!src) return resolve(null)
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => resolve(null)
img.src = src.includes('?') ? src + '&_ex=1' : src + '?_ex=1'
})
}
function drawRR(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
function drawTopRR(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h)
ctx.lineTo(x, y + h)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
async function captureAsJpeg(element: HTMLElement, filename: string, zoom: number): Promise<void> {
const scrollParent = element.closest('.overflow-auto') as HTMLElement | null
const savedSL = scrollParent?.scrollLeft ?? 0
const savedST = scrollParent?.scrollTop ?? 0
if (scrollParent) {
scrollParent.scrollLeft = 0
scrollParent.scrollTop = 0
}
await new Promise<void>((r) => requestAnimationFrame(() => r()))
const origin = element.getBoundingClientRect()
const W = Math.round(origin.width / zoom)
const H = Math.round(origin.height / zoom)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const canvas = document.createElement('canvas')
canvas.width = W * dpr
canvas.height = H * dpr
const ctx = canvas.getContext('2d')!
ctx.scale(dpr, dpr)
ctx.fillStyle = '#f3f4f6'
ctx.fillRect(0, 0, W, H)
// 1. Connector lines from SVG paths (already in logical space)
const svgEl = element.querySelector('svg')
if (svgEl) {
ctx.save()
ctx.strokeStyle = '#cbd5e1'
ctx.lineWidth = 1.25
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
for (const pathEl of Array.from(svgEl.querySelectorAll('path'))) {
const d = pathEl.getAttribute('d')
if (!d) continue
const tokens = d.trim().split(/[\s,]+/)
let i = 0
ctx.beginPath()
while (i < tokens.length) {
const cmd = tokens[i++]
if (cmd === 'M') ctx.moveTo(parseFloat(tokens[i++]), parseFloat(tokens[i++]))
else if (cmd === 'L') ctx.lineTo(parseFloat(tokens[i++]), parseFloat(tokens[i++]))
}
ctx.stroke()
}
ctx.restore()
}
// 2. Node cards
const cards = Array.from(element.querySelectorAll('[data-card]')) as HTMLElement[]
const srcSet = new Set<string>()
for (const card of cards) {
for (const img of Array.from(card.querySelectorAll('img'))) {
const src = (img as HTMLImageElement).src
if (src) srcSet.add(src)
}
}
const imgCache = new Map<string, HTMLImageElement | null>()
await Promise.all([...srcSet].map(async (src) => imgCache.set(src, await loadImg(src))))
for (const card of cards) {
const cr = card.getBoundingClientRect()
const cx = Math.round((cr.left - origin.left) / zoom)
const cy = Math.round((cr.top - origin.top) / zoom)
const cw = Math.round(cr.width / zoom)
const ch = Math.round(cr.height / zoom)
const depth = parseInt(card.dataset.depth || '0')
const lc = LEVEL_COLORS[depth % LEVEL_COLORS.length]
const headerHex = TW_HEX[lc.header] || '#3b82f6'
const borderHex = TW_HEX[lc.border] || '#bfdbfe'
const rad = 12
// White card with shadow
ctx.save()
ctx.shadowColor = 'rgba(0,0,0,0.08)'
ctx.shadowBlur = 6
ctx.shadowOffsetY = 2
drawRR(ctx, cx, cy, cw, ch, rad)
ctx.fillStyle = '#ffffff'
ctx.fill()
ctx.restore()
ctx.save()
drawRR(ctx, cx + 0.5, cy + 0.5, cw - 1, ch - 1, rad)
ctx.strokeStyle = borderHex
ctx.lineWidth = 1
ctx.stroke()
ctx.restore()
const headerEl = card.querySelector('[data-header]') as HTMLElement | null
const headerH = headerEl ? Math.round(headerEl.getBoundingClientRect().height / zoom) : 34
ctx.save()
drawTopRR(ctx, cx, cy, cw, headerH, rad)
ctx.fillStyle = headerHex
ctx.fill()
ctx.restore()
const nameEl = card.querySelector('[data-node-name]') as HTMLElement | null
if (nameEl) {
ctx.save()
ctx.font = `600 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
ctx.fillStyle = '#ffffff'
ctx.textBaseline = 'middle'
ctx.fillText(nameEl.textContent?.trim() || '', cx + 22, cy + headerH / 2, cw - 30)
ctx.restore()
}
const usersVisible = card.dataset.usersVisible === 'true'
const userRows = Array.from(card.querySelectorAll('[data-user-row]')) as HTMLElement[]
if (usersVisible && userRows.length === 0) {
ctx.save()
ctx.font = `12px sans-serif`
ctx.fillStyle = '#94a3b8'
ctx.textBaseline = 'middle'
ctx.fillText('—', cx + 12, cy + headerH + 16)
ctx.restore()
}
for (const row of userRows) {
const rr = row.getBoundingClientRect()
const ry = Math.round((rr.top - origin.top) / zoom)
const rh = Math.round(rr.height / zoom)
const aCX = cx + 12 + 12
const aCY = ry + rh / 2
const ar = 12
ctx.save()
ctx.beginPath()
ctx.arc(aCX, aCY, ar, 0, Math.PI * 2)
ctx.fillStyle = '#e0e7ff'
ctx.fill()
ctx.restore()
const imgEl = row.querySelector('img') as HTMLImageElement | null
const cachedImg = imgEl?.src ? (imgCache.get(imgEl.src) ?? null) : null
if (cachedImg) {
ctx.save()
ctx.beginPath()
ctx.arc(aCX, aCY, ar, 0, Math.PI * 2)
ctx.clip()
ctx.drawImage(cachedImg, aCX - ar, aCY - ar, ar * 2, ar * 2)
ctx.restore()
} else {
const nameSpan = row.querySelector('[data-user-name]') as HTMLElement | null
const nm = nameSpan?.textContent?.trim() || ''
const initials = nm.split(' ').map((p) => p[0]).filter(Boolean).slice(0, 2).join('').toUpperCase()
ctx.save()
ctx.font = `bold 8px sans-serif`
ctx.fillStyle = '#4f46e5'
ctx.textBaseline = 'middle'
ctx.textAlign = 'center'
ctx.fillText(initials, aCX, aCY)
ctx.restore()
}
const nameSpan = row.querySelector('[data-user-name]') as HTMLElement | null
if (nameSpan) {
ctx.save()
ctx.font = `11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
ctx.fillStyle = '#475569'
ctx.textBaseline = 'middle'
ctx.fillText(nameSpan.textContent?.trim() || '', cx + 12 + 24 + 8, ry + rh / 2, cw - 52)
ctx.restore()
}
}
}
if (scrollParent) {
scrollParent.scrollLeft = savedSL
scrollParent.scrollTop = savedST
}
canvas.toBlob(
(blob) => {
if (!blob) return
const a = document.createElement('a')
a.download = filename
a.href = URL.createObjectURL(blob)
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 2000)
},
'image/jpeg',
0.93,
)
}
// ---- end JPG Export helpers ----
const LEVEL_COLORS = [
{ border: 'border-blue-200', header: 'bg-blue-500', badge: 'bg-blue-50 text-blue-600' },
{ border: 'border-indigo-200', header: 'bg-indigo-500', badge: 'bg-indigo-50 text-indigo-600' },
{ border: 'border-cyan-200', header: 'bg-cyan-500', badge: 'bg-cyan-50 text-cyan-700' },
{ border: 'border-teal-200', header: 'bg-teal-500', badge: 'bg-teal-50 text-teal-700' },
{ border: 'border-violet-200', header: 'bg-violet-500', badge: 'bg-violet-50 text-violet-600' },
{ border: 'border-sky-200', header: 'bg-sky-500', badge: 'bg-sky-50 text-sky-700' },
{ border: 'border-slate-200', header: 'bg-slate-500', badge: 'bg-slate-50 text-slate-600' },
]
interface TreeNode extends OrgChartNodeDto {
children: TreeNode[]
}
interface NodePosition {
x: number
y: number
}
type PositionMap = Record<string, NodePosition>
type NodeRefMap = Record<string, HTMLDivElement | null>
type CardRefMap = Record<string, HTMLDivElement | null>
interface EdgeLink {
parentId: string
childId: string
}
function buildTree(nodes: OrgChartNodeDto[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const node of nodes) {
map.set(node.id, { ...node, children: [] })
}
for (const node of map.values()) {
if (node.parentId && map.has(node.parentId)) {
map.get(node.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
function buildEdges(tree: TreeNode[]): EdgeLink[] {
const edges: EdgeLink[] = []
const visit = (node: TreeNode) => {
for (const child of node.children) {
edges.push({ parentId: node.id, childId: child.id })
visit(child)
}
}
for (const root of tree) {
visit(root)
}
return edges
}
function UserAvatar({ user, tenantId }: { user: OrgChartUserDto; tenantId?: string }) {
const initials = (user.fullName || user.userName || '?')
.split(' ')
.map((w) => w[0]?.toUpperCase() ?? '')
.slice(0, 2)
.join('')
const src = AVATAR_URL(user.id, tenantId)
return (
<Avatar
shape="circle"
size={24}
src={src}
title={user.fullName || user.userName}
className="border border-indigo-200 bg-indigo-100 text-indigo-700 font-semibold text-xs flex-shrink-0"
>
{initials}
</Avatar>
)
}
function OrgChartNode({
node,
mode,
depth,
showUsers,
tenantId,
zoom,
positions,
setPositions,
nodeRefs,
cardRefs,
requestRepaint,
applySelfTransform = true,
}: {
node: TreeNode
mode: ViewMode
depth: number
showUsers: boolean
tenantId?: string
zoom: number
positions: PositionMap
setPositions: React.Dispatch<React.SetStateAction<PositionMap>>
nodeRefs: React.MutableRefObject<NodeRefMap>
cardRefs: React.MutableRefObject<CardRefMap>
requestRepaint: () => void
applySelfTransform?: boolean
}) {
const [collapsed, setCollapsed] = useState(false)
const [dragging, setDragging] = useState(false)
const hasChildren = node.children.length > 0
const hasUsers = showUsers && node.users.length > 0
const position = positions[node.id] ?? { x: 0, y: 0 }
const levelColor = LEVEL_COLORS[depth % LEVEL_COLORS.length]
const borderColor = levelColor.border
const headerBg = levelColor.header
const badgeBg = levelColor.badge
const handleDragStart = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
if (target.closest('[data-stop-drag="true"]')) {
return
}
e.preventDefault()
setDragging(true)
const startX = e.clientX
const startY = e.clientY
const initial = positions[node.id] ?? { x: 0, y: 0 }
const handleMove = (moveEvent: MouseEvent) => {
const dx = (moveEvent.clientX - startX) / zoom
const dy = (moveEvent.clientY - startY) / zoom
setPositions((prev) => ({
...prev,
[node.id]: {
x: initial.x + dx,
y: initial.y + dy,
},
}))
requestRepaint()
}
const handleUp = () => {
setDragging(false)
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleUp)
requestRepaint()
}
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleUp)
}
return (
<div
ref={(el) => {
nodeRefs.current[node.id] = el
}}
className="flex flex-col items-center select-none"
style={applySelfTransform ? { transform: `translate(${position.x}px, ${position.y}px)` } : undefined}
>
{/* Node card */}
<div
ref={(el) => {
cardRefs.current[node.id] = el
}}
onMouseDown={handleDragStart}
data-card=""
data-depth={depth}
data-users-visible={showUsers ? 'true' : 'false'}
className={`relative bg-white border ${borderColor} rounded-xl shadow-sm w-52 hover:shadow-md transition-shadow`}
style={{ cursor: dragging ? 'grabbing' : 'grab' }}
>
{/* Header bar */}
<div data-header="" className={`${headerBg} rounded-t-xl px-3 py-2 flex items-center gap-2`}>
{mode === 'department' ? (
<FaBuilding className="w-3 h-3 text-white opacity-80 flex-shrink-0" />
) : (
<FaBriefcase className="w-3 h-3 text-white opacity-80 flex-shrink-0" />
)}
<span data-node-name="" className="text-white font-semibold text-xs truncate flex-1">{node.name}</span>
{hasChildren && (
<button
data-stop-drag="true"
onClick={() => {
setCollapsed((c) => !c)
requestRepaint()
}}
className="text-white opacity-70 hover:opacity-100 transition-opacity ml-1 flex-shrink-0"
>
{collapsed ? (
<FaChevronRight className="w-2.5 h-2.5" />
) : (
<FaChevronDown className="w-2.5 h-2.5" />
)}
</button>
)}
</div>
{/* Department label for job positions */}
{mode === 'jobPosition' && node.departmentName && (
<div className="px-3 pt-2">
<span
className={`inline-block text-xs px-2 py-0.5 rounded-full font-medium ${badgeBg}`}
>
{node.departmentName}
</span>
</div>
)}
{/* Users section */}
{showUsers && (
<div className="px-3 py-2">
{hasUsers ? (
<div className="flex flex-col gap-1">
{node.users.map((user) => (
<div key={user.id} data-user-row="" className="flex items-center gap-2">
<UserAvatar user={user} tenantId={tenantId} />
<span data-user-name="" className="text-xs text-slate-600 truncate">
{user.fullName || user.userName}
</span>
</div>
))}
</div>
) : (
<p className="text-xs text-slate-400 italic"></p>
)}
</div>
)}
{/* Child count badge */}
{hasChildren && (
<div className="absolute -bottom-2.5 left-1/2 -translate-x-1/2 bg-white border border-slate-200 rounded-full px-2 py-0.5 text-xs text-slate-500 shadow-sm whitespace-nowrap z-10">
{node.children.length}
</div>
)}
</div>
{/* Children */}
{hasChildren && !collapsed && (
<div className="mt-10 flex items-start gap-10">
{node.children.map((child, idx) => {
const childPosition = positions[child.id] ?? { x: 0, y: 0 }
return (
<div
key={child.id}
className="flex flex-col items-center relative"
style={{ transform: `translate(${childPosition.x}px, ${childPosition.y}px)` }}
>
{/* Recursive child */}
<OrgChartNode
node={child}
mode={mode}
depth={depth + 1}
showUsers={showUsers}
tenantId={tenantId}
zoom={zoom}
positions={positions}
setPositions={setPositions}
nodeRefs={nodeRefs}
cardRefs={cardRefs}
requestRepaint={requestRepaint}
applySelfTransform={false}
/>
</div>
)
})}
</div>
)}
</div>
)
}
function OrgChartTree({
nodes,
mode,
showUsers,
tenantId,
zoom,
positions,
setPositions,
edges,
}: {
nodes: TreeNode[]
mode: ViewMode
showUsers: boolean
tenantId?: string
zoom: number
positions: PositionMap
setPositions: React.Dispatch<React.SetStateAction<PositionMap>>
edges: EdgeLink[]
}) {
const containerRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<NodeRefMap>({})
const cardRefs = useRef<CardRefMap>({})
const [paths, setPaths] = useState<string[]>([])
const repaintRafRef = useRef<number>()
const recomputePaths = useCallback(() => {
const container = containerRef.current
if (!container) {
setPaths([])
return
}
const containerRect = container.getBoundingClientRect()
const zoomFactor = zoom || 1
const nextPaths: string[] = []
const parentToChildren = new Map<string, string[]>()
for (const edge of edges) {
const list = parentToChildren.get(edge.parentId) ?? []
list.push(edge.childId)
parentToChildren.set(edge.parentId, list)
}
for (const [parentId, childIds] of parentToChildren.entries()) {
const parentCard = cardRefs.current[parentId]
if (!parentCard) {
continue
}
const parentRect = parentCard.getBoundingClientRect()
const fromX = (parentRect.left - containerRect.left + parentRect.width / 2) / zoomFactor
const fromY = (parentRect.bottom - containerRect.top) / zoomFactor
const childPoints = childIds
.map((childId) => {
const childCard = cardRefs.current[childId]
if (!childCard) {
return null
}
const childRect = childCard.getBoundingClientRect()
return {
x: (childRect.left - containerRect.left + childRect.width / 2) / zoomFactor,
y: (childRect.top - containerRect.top) / zoomFactor,
}
})
.filter((point): point is { x: number; y: number } => point !== null)
if (!childPoints.length) {
continue
}
const minChildY = Math.min(...childPoints.map((p) => p.y))
const rawGap = minChildY - fromY
const drop = rawGap > 0 ? Math.max(14, Math.min(40, rawGap * 0.4)) : 14
const busY = fromY + drop
const allX = [fromX, ...childPoints.map((p) => p.x)]
const minX = Math.min(...allX)
const maxX = Math.max(...allX)
// Parent trunk to bus
nextPaths.push(`M ${fromX} ${fromY} L ${fromX} ${busY}`)
// Shared horizontal bus across all children
nextPaths.push(`M ${minX} ${busY} L ${maxX} ${busY}`)
// Child drops from bus to each child top
for (const childPoint of childPoints) {
nextPaths.push(`M ${childPoint.x} ${busY} L ${childPoint.x} ${childPoint.y}`)
}
}
setPaths(nextPaths)
}, [edges, zoom])
const requestRepaint = useCallback(() => {
if (repaintRafRef.current) {
cancelAnimationFrame(repaintRafRef.current)
}
repaintRafRef.current = requestAnimationFrame(() => {
recomputePaths()
})
}, [recomputePaths])
useEffect(() => {
requestRepaint()
}, [requestRepaint, nodes, positions, zoom, showUsers])
useEffect(() => {
const raf = requestAnimationFrame(() => {
requestRepaint()
})
return () => {
cancelAnimationFrame(raf)
}
}, [requestRepaint, showUsers])
useEffect(() => {
const onResize = () => requestRepaint()
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
if (repaintRafRef.current) {
cancelAnimationFrame(repaintRafRef.current)
}
}
}, [requestRepaint])
if (nodes.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 text-slate-400">
<FaSitemap className="w-12 h-12 mb-4 opacity-30" />
<p className="text-sm">Veri bulunamadı</p>
</div>
)
}
return (
<div ref={containerRef} className="relative p-6">
<svg className="pointer-events-none absolute inset-0 w-full h-full overflow-visible">
{paths.map((path, index) => (
<path
key={`${index}-${path}`}
d={path}
fill="none"
stroke="#cbd5e1"
strokeWidth="1.25"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
</svg>
<div className="relative z-10 flex gap-8 justify-center flex-wrap items-start">
{nodes.map((root) => (
<OrgChartNode
key={root.id}
node={root}
mode={mode}
depth={0}
showUsers={showUsers}
tenantId={tenantId}
zoom={zoom}
positions={positions}
setPositions={setPositions}
nodeRefs={nodeRefs}
cardRefs={cardRefs}
requestRepaint={requestRepaint}
/>
))}
</div>
</div>
)
}
const OrgChart = () => {
const { translate } = useLocalization()
const [mode, setMode] = useState<ViewMode>('department')
const [nodes, setNodes] = useState<OrgChartNodeDto[]>([])
const [loading, setLoading] = useState(false)
const [zoom, setZoom] = useState(1)
const [showUsers, setShowUsers] = useState(true)
const [positions, setPositions] = useState<PositionMap>({})
const [exporting, setExporting] = useState(false)
const chartRef = useRef<HTMLDivElement>(null)
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const tenantId = useStoreState((state) => state.auth.tenant?.tenantId)
const clampZoom = (value: number) => Math.max(0.3, Math.min(3, value))
const fetchData = async (viewMode: ViewMode) => {
setLoading(true)
try {
if (viewMode === 'department') {
const res = await getOrgChartDepartments()
setNodes(res.data ?? [])
} else {
const res = await getOrgChartJobPositions()
setNodes(res.data ?? [])
}
} catch {
setNodes([])
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData(mode)
setPositions({})
}, [mode])
const handleWheelZoom = (e: React.WheelEvent<HTMLDivElement>) => {
if (!e.ctrlKey) {
return
}
e.preventDefault()
const next = e.deltaY < 0 ? zoom + 0.1 : zoom - 0.1
setZoom(clampZoom(next))
}
const handleZoomIn = () => setZoom((prev) => clampZoom(prev + 0.1))
const handleZoomOut = () => setZoom((prev) => clampZoom(prev - 0.1))
const handleZoomReset = () => setZoom(1)
const handleExportJpg = async () => {
if (!chartRef.current || exporting) return
setExporting(true)
try {
const label = mode === 'department' ? 'departman' : 'pozisyon'
await captureAsJpeg(chartRef.current, `org-chart-${label}.jpg`, zoom)
} finally {
setExporting(false)
}
}
const tree = useMemo(() => buildTree(nodes), [nodes])
const edges = useMemo(() => buildEdges(tree), [tree])
return (
<>
<Helmet>
<title>{APP_NAME} Organizasyon Şeması</title>
</Helmet>
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between pb-1 border-b">
<div className="flex items-center gap-3">
{MenuIcon}
<h4 className="text-sm font-medium">
{translate('::App.Definitions.OrgChart') || 'Organizasyon Şeması'}
</h4>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={() => setMode('department')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
mode === 'department'
? 'bg-blue-600 text-white shadow-sm'
: 'text-slate-600 hover:text-slate-800'
}`}
>
<FaBuilding className="w-3.5 h-3.5" />
{translate('::App.Hr.Department') || 'Departman'}
</button>
<button
onClick={() => setMode('jobPosition')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
mode === 'jobPosition'
? 'bg-purple-600 text-white shadow-sm'
: 'text-slate-600 hover:text-slate-800'
}`}
>
<FaBriefcase className="w-3.5 h-3.5" />
{translate('::App.Hr.JobPosition') || 'Pozisyon'}
</button>
</div>
<div className="flex items-center bg-slate-100 rounded-lg p-1 gap-1">
<label className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-slate-600 select-none">
<input
type="checkbox"
checked={showUsers}
onChange={(e) => setShowUsers(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{translate('::App.Definitions.OrgChart.ShowUsers') || 'Kullanıcılar'}</span>
</label>
</div>
<div className="flex items-center bg-slate-100 rounded-lg p-1 gap-1">
<button
onClick={handleZoomOut}
className="p-2 rounded-md text-slate-600 hover:text-slate-800 hover:bg-white"
title="Zoom Out"
>
<FaSearchMinus className="w-3.5 h-3.5" />
</button>
<span className="text-xs font-medium text-slate-600 px-1 min-w-[46px] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 rounded-md text-slate-600 hover:text-slate-800 hover:bg-white"
title="Zoom In"
>
<FaSearchPlus className="w-3.5 h-3.5" />
</button>
<button
onClick={handleZoomReset}
className="p-2 rounded-md text-slate-600 hover:text-slate-800 hover:bg-white"
title="Reset Zoom"
>
<FaUndo className="w-3.5 h-3.5" />
</button>
</div>
<button
onClick={handleExportJpg}
disabled={exporting || loading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium bg-slate-100 text-slate-600 hover:bg-slate-200 disabled:opacity-50 transition-colors"
title="JPG olarak indir"
>
<FaFileImage className="w-3.5 h-3.5" />
{exporting ? 'İşleniyor…' : 'Export'}
</button>
</div>
</div>
{/* Chart area */}
<div className="flex-1 overflow-auto" onWheel={handleWheelZoom}>
{loading ? (
<div className="flex items-center justify-center h-64">
<Loading loading={true} />
</div>
) : (
<div
ref={chartRef}
className="w-max min-w-full"
style={{
transform: `scale(${zoom})`,
transformOrigin: 'top center',
}}
>
<OrgChartTree
nodes={tree}
mode={mode}
showUsers={showUsers}
tenantId={tenantId}
zoom={zoom}
positions={positions}
setPositions={setPositions}
edges={edges}
/>
</div>
)}
</div>
</div>
</>
)
}
export default OrgChart