diff --git a/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs b/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs index 5138b85..4a179d6 100644 --- a/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs +++ b/api/src/Sozsoft.Platform.Application.Contracts/LookUpQueryValues.cs @@ -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 = diff --git a/api/src/Sozsoft.Platform.Application.Contracts/OrgChart/OrgChartDto.cs b/api/src/Sozsoft.Platform.Application.Contracts/OrgChart/OrgChartDto.cs new file mode 100644 index 0000000..13efa91 --- /dev/null +++ b/api/src/Sozsoft.Platform.Application.Contracts/OrgChart/OrgChartDto.cs @@ -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 Users { get; set; } = []; +} diff --git a/api/src/Sozsoft.Platform.Application/OrgChart/OrgChartAppService.cs b/api/src/Sozsoft.Platform.Application/OrgChart/OrgChartAppService.cs new file mode 100644 index 0000000..f9f4d0d --- /dev/null +++ b/api/src/Sozsoft.Platform.Application/OrgChart/OrgChartAppService.cs @@ -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 _departmentRepository; + private readonly IRepository _jobPositionRepository; + private readonly IIdentityUserRepository _userRepository; + + public OrgChartAppService( + IRepository departmentRepository, + IRepository jobPositionRepository, + IIdentityUserRepository userRepository) + { + _departmentRepository = departmentRepository; + _jobPositionRepository = jobPositionRepository; + _userRepository = userRepository; + } + + [HttpGet("departments")] + public async Task> 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(); + + 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> 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; + } +} diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index a79d184..2a7afb2 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -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", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json index f570542..e9e87f6 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/MenusData.json @@ -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", diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json index 2a8bbb0..25a8c13 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/PermissionsData.json @@ -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", @@ -3309,4 +3314,4 @@ "MenuGroup": "Erp|Kurs" } ] -} +} \ No newline at end of file diff --git a/ui/src/services/orgChart.service.ts b/ui/src/services/orgChart.service.ts new file mode 100644 index 0000000..4fea9d4 --- /dev/null +++ b/ui/src/services/orgChart.service.ts @@ -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({ + method: 'GET', + url: '/api/app/org-chart/departments', + }) + +export const getOrgChartJobPositions = () => + apiService.fetchData({ + method: 'GET', + url: '/api/app/org-chart/job-positions', + }) diff --git a/ui/src/views/admin/hr/OrgChart.tsx b/ui/src/views/admin/hr/OrgChart.tsx new file mode 100644 index 0000000..44a13cf --- /dev/null +++ b/ui/src/views/admin/hr/OrgChart.tsx @@ -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 = { + '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 { + 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 { + 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((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() + 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() + 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 +type NodeRefMap = Record +type CardRefMap = Record + +interface EdgeLink { + parentId: string + childId: string +} + +function buildTree(nodes: OrgChartNodeDto[]): TreeNode[] { + const map = new Map() + 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 ( + + {initials} + + ) +} + +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> + nodeRefs: React.MutableRefObject + cardRefs: React.MutableRefObject + 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) => { + 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 ( +
{ + 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 */} +
{ + 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 */} +
+ {mode === 'department' ? ( + + ) : ( + + )} + {node.name} + {hasChildren && ( + + )} +
+ + {/* Department label for job positions */} + {mode === 'jobPosition' && node.departmentName && ( +
+ + {node.departmentName} + +
+ )} + + {/* Users section */} + {showUsers && ( +
+ {hasUsers ? ( +
+ {node.users.map((user) => ( +
+ + + {user.fullName || user.userName} + +
+ ))} +
+ ) : ( +

+ )} +
+ )} + + {/* Child count badge */} + {hasChildren && ( +
+ {node.children.length} +
+ )} +
+ + {/* Children */} + {hasChildren && !collapsed && ( +
+ {node.children.map((child, idx) => { + const childPosition = positions[child.id] ?? { x: 0, y: 0 } + + return ( +
+ {/* Recursive child */} + +
+ ) + })} +
+ )} +
+ ) +} + +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> + edges: EdgeLink[] +}) { + const containerRef = useRef(null) + const nodeRefs = useRef({}) + const cardRefs = useRef({}) + const [paths, setPaths] = useState([]) + const repaintRafRef = useRef() + + 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() + + 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 ( +
+ +

Veri bulunamadı

+
+ ) + } + + return ( +
+ + {paths.map((path, index) => ( + + ))} + + +
+ {nodes.map((root) => ( + + ))} +
+
+ ) +} + +const OrgChart = () => { + const { translate } = useLocalization() + const [mode, setMode] = useState('department') + const [nodes, setNodes] = useState([]) + const [loading, setLoading] = useState(false) + const [zoom, setZoom] = useState(1) + const [showUsers, setShowUsers] = useState(true) + const [positions, setPositions] = useState({}) + const [exporting, setExporting] = useState(false) + const chartRef = useRef(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) => { + 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 ( + <> + + {APP_NAME} — Organizasyon Şeması + + +
+ {/* Toolbar */} +
+
+ {MenuIcon} +

+ {translate('::App.Definitions.OrgChart') || 'Organizasyon Şeması'} +

+
+ +
+
+ + +
+ +
+ +
+ +
+ + + {Math.round(zoom * 100)}% + + + +
+ + +
+
+ + {/* Chart area */} +
+ {loading ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ + ) +} + +export default OrgChart