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\" " +
|
$"ON \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"Key\" = \"{FullNameTable(TableNameEnum.LanguageText)}\".\"Key\" " +
|
||||||
$"AND \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"ResourceName\" = \"{FullNameTable(TableNameEnum.LanguageText)}\".\"ResourceName\" " +
|
$"AND \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"ResourceName\" = \"{FullNameTable(TableNameEnum.LanguageText)}\".\"ResourceName\" " +
|
||||||
$"WHERE " +
|
$"WHERE " +
|
||||||
$"\"{FullNameTable(TableNameEnum.LanguageKey)}\".\"IsDeleted\" = 'false' " +
|
$"\"{FullNameTable(TableNameEnum.LanguageText)}\".\"CultureName\" = 'tr' " +
|
||||||
$"AND \"{FullNameTable(TableNameEnum.LanguageText)}\".\"IsDeleted\" = 'false' " +
|
|
||||||
$"AND \"{FullNameTable(TableNameEnum.LanguageText)}\".\"CultureName\" = 'tr' " +
|
|
||||||
$"ORDER BY \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"Key\";";
|
$"ORDER BY \"{FullNameTable(TableNameEnum.LanguageKey)}\".\"Key\";";
|
||||||
|
|
||||||
public static string CountryValues =
|
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...",
|
"en": "No record found...",
|
||||||
"tr": "Kayıt Bulunamadı..."
|
"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",
|
"resourceName": "Platform",
|
||||||
"key": "App.Definitions.GlobalSearch",
|
"key": "App.Definitions.GlobalSearch",
|
||||||
|
|
|
||||||
|
|
@ -245,6 +245,13 @@
|
||||||
"routeType": "protected",
|
"routeType": "protected",
|
||||||
"authority": ["Abp.Identity.OrganizationUnits"]
|
"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",
|
"key": "admin.forum",
|
||||||
"path": "/admin/forum",
|
"path": "/admin/forum",
|
||||||
|
|
@ -964,6 +971,16 @@
|
||||||
"RequiredPermissionName": "App.Definitions.JobPosition",
|
"RequiredPermissionName": "App.Definitions.JobPosition",
|
||||||
"IsDisabled": false
|
"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",
|
"ParentCode": "App.Administration",
|
||||||
"Code": "App.Administration.Restrictions",
|
"Code": "App.Administration.Restrictions",
|
||||||
|
|
|
||||||
|
|
@ -1603,7 +1603,6 @@
|
||||||
"MultiTenancySide": 2,
|
"MultiTenancySide": 2,
|
||||||
"MenuGroup": "Erp|Kurs"
|
"MenuGroup": "Erp|Kurs"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"GroupName": "App.Saas",
|
"GroupName": "App.Saas",
|
||||||
"Name": "App.Orders.SalesOrderItem",
|
"Name": "App.Orders.SalesOrderItem",
|
||||||
|
|
@ -2522,7 +2521,6 @@
|
||||||
"MultiTenancySide": 3,
|
"MultiTenancySide": 3,
|
||||||
"MenuGroup": "Erp|Kurs"
|
"MenuGroup": "Erp|Kurs"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"GroupName": "App.Administration",
|
"GroupName": "App.Administration",
|
||||||
"Name": "App.Definitions.Department",
|
"Name": "App.Definitions.Department",
|
||||||
|
|
@ -2577,7 +2575,6 @@
|
||||||
"MultiTenancySide": 3,
|
"MultiTenancySide": 3,
|
||||||
"MenuGroup": "Erp|Kurs"
|
"MenuGroup": "Erp|Kurs"
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"GroupName": "App.Administration",
|
"GroupName": "App.Administration",
|
||||||
"Name": "App.Definitions.JobPosition",
|
"Name": "App.Definitions.JobPosition",
|
||||||
|
|
@ -2632,7 +2629,15 @@
|
||||||
"MultiTenancySide": 3,
|
"MultiTenancySide": 3,
|
||||||
"MenuGroup": "Erp|Kurs"
|
"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",
|
"GroupName": "App.Administration",
|
||||||
"Name": "App.Restrictions.WorkHour",
|
"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