Organizasyon Şeması komponenti
This commit is contained in:
parent
31f632d16a
commit
4444fce93b
8 changed files with 1140 additions and 8 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
29
ui/src/services/orgChart.service.ts
Normal file
29
ui/src/services/orgChart.service.ts
Normal 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',
|
||||
})
|
||||
925
ui/src/views/admin/hr/OrgChart.tsx
Normal file
925
ui/src/views/admin/hr/OrgChart.tsx
Normal 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
|
||||
Loading…
Reference in a new issue