ActivityAppService ve Dosya Upload işlemi

This commit is contained in:
Sedat Öztürk 2025-10-15 00:10:47 +03:00
parent 6f91454ae8
commit 8564bff367
9 changed files with 583 additions and 340 deletions

View file

@ -4,7 +4,7 @@ using Volo.Abp.Content;
namespace Kurs.Platform.Entities; namespace Kurs.Platform.Entities;
public class ActivityDto : EntityDto<Guid> public class ActivityDto : FullAuditedEntityDto<Guid>
{ {
public Guid? TenantId { get; set; } public Guid? TenantId { get; set; }
public string EntityName { get; set; } public string EntityName { get; set; }
@ -12,7 +12,8 @@ public class ActivityDto : EntityDto<Guid>
public string Type { get; set; } public string Type { get; set; }
public string Subject { get; set; } public string Subject { get; set; }
public string Content { get; set; } public string Content { get; set; }
public string FilesJson { get; set; } public string FilesJson { get; set; }
public string CreateUserName { get; set; }
public IRemoteStreamContent[] Files { get; set; } public IRemoteStreamContent[] Files { get; set; }
} }

View file

@ -0,0 +1,10 @@
namespace Kurs.Platform.Activities;
public class ActivityFileDto
{
public string FileName { get; set; } // Dosya adı
public string FileType { get; set; } // MIME tipi
public long FileSize { get; set; } // Boyut (byte)
public string SavedFileName { get; set; } // Blob üzerinde kaydedilen dosya adı
public string FileBase64 { get; set; } // Dosya içeriği
}

View file

@ -0,0 +1,7 @@
using Volo.Abp.Application.Dtos;
public class ActivityListRequestDto : PagedAndSortedResultRequestDto
{
public string EntityName { get; set; }
public string EntityId { get; set; }
}

View file

@ -1,13 +1,22 @@
using System; using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Dynamic.Core;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kurs.Platform.BlobStoring; using Kurs.Platform.BlobStoring;
using Kurs.Platform.Entities; using Kurs.Platform.Entities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Volo.Abp;
using Volo.Abp.Application.Dtos; using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services; using Volo.Abp.Application.Services;
using Volo.Abp.BlobStoring; using Volo.Abp.BlobStoring;
using Volo.Abp.Domain.Repositories; using Volo.Abp.Domain.Repositories;
using Volo.Abp.Identity;
namespace Kurs.Platform.Activities; namespace Kurs.Platform.Activities;
@ -16,16 +25,19 @@ public class ActivityAppService : CrudAppService<
Activity, Activity,
ActivityDto, ActivityDto,
Guid, Guid,
PagedAndSortedResultRequestDto> ActivityListRequestDto>
{ {
private readonly IBlobContainer<ActivityBlobContainer> ActivityBlobContainer; private readonly IBlobContainer<ActivityBlobContainer> _activityBlobContainer;
private readonly IRepository<IdentityUser, Guid> _repositoryUser;
public ActivityAppService( public ActivityAppService(
IRepository<Activity, Guid> repo, IRepository<Activity, Guid> repo,
IBlobContainer<ActivityBlobContainer> activityBlobContainer IBlobContainer<ActivityBlobContainer> activityBlobContainer,
IRepository<IdentityUser, Guid> repositoryUser
) : base(repo) ) : base(repo)
{ {
ActivityBlobContainer = activityBlobContainer; _activityBlobContainer = activityBlobContainer;
_repositoryUser = repositoryUser;
// CreatePolicyName = $"{AppCodes.Listforms.Listform}.Create"; // CreatePolicyName = $"{AppCodes.Listforms.Listform}.Create";
// UpdatePolicyName = $"{AppCodes.Listforms.Listform}.Update"; // UpdatePolicyName = $"{AppCodes.Listforms.Listform}.Update";
@ -38,8 +50,143 @@ public class ActivityAppService : CrudAppService<
// } // }
} }
public override Task<ActivityDto> CreateAsync([FromForm] ActivityDto input) public async Task<ActivityFileDto> GetDownloadAsync(string savedFileName)
{ {
return base.CreateAsync(input); if (string.IsNullOrWhiteSpace(savedFileName))
throw new UserFriendlyException("Dosya adı geçersiz");
var stream = await _activityBlobContainer.GetAsync(savedFileName);
if (stream == null)
throw new UserFriendlyException("Dosya bulunamadı");
var activities = await Repository.GetListAsync();
var fileDto = activities
.SelectMany(a => JsonSerializer.Deserialize<List<ActivityFileDto>>(a.FilesJson ?? "[]"))
.FirstOrDefault(f => f.SavedFileName == savedFileName);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var bytes = ms.ToArray();
// Base64 olarak encode ediyoruz
return new ActivityFileDto
{
FileName = fileDto?.FileName ?? savedFileName,
FileType = fileDto?.FileType ?? "application/octet-stream",
FileBase64 = Convert.ToBase64String(bytes) // <-- byte[] yerine Base64 string
};
}
public override async Task<PagedResultDto<ActivityDto>> GetListAsync(ActivityListRequestDto input)
{
// 1⃣ Filtreleme
var query = await Repository.GetQueryableAsync();
if (!string.IsNullOrWhiteSpace(input.EntityName))
query = query.Where(a => a.EntityName == input.EntityName);
if (!string.IsNullOrWhiteSpace(input.EntityId))
query = query.Where(a => a.EntityId == input.EntityId);
// 2⃣ Sıralama (opsiyonel)
if (!string.IsNullOrWhiteSpace(input.Sorting))
query = query.OrderBy(input.Sorting);
else
query = query.OrderByDescending(a => a.CreationTime);
// 3⃣ Paging
var totalCount = await AsyncExecuter.CountAsync(query);
var activities = await AsyncExecuter.ToListAsync(
query.Skip(input.SkipCount).Take(input.MaxResultCount)
);
// 4⃣ Kullanıcı bilgilerini al
var userIds = activities.Select(a => a.CreatorId).Distinct().ToList();
var userQueryable = await _repositoryUser.GetQueryableAsync();
var users = await userQueryable
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.UserName })
.ToListAsync();
// 5⃣ DTO map ve kullanıcı adı ekleme
var activityDtos = activities.Select(a =>
{
var dto = ObjectMapper.Map<Activity, ActivityDto>(a);
dto.CreateUserName = users.FirstOrDefault(u => u.Id == a.CreatorId)?.UserName ?? "Unknown";
return dto;
}).ToList();
// 6⃣ Sonuç dön
return new PagedResultDto<ActivityDto>(totalCount, activityDtos);
}
public override async Task<ActivityDto> CreateAsync([FromForm] ActivityDto input)
{
var fileDtos = new List<ActivityFileDto>();
if (input.Files != null && input.Files.Length > 0)
{
foreach (var file in input.Files)
{
await using var stream = file.GetStream();
var savedFileName = $"{Guid.NewGuid()}_{file.FileName}";
await _activityBlobContainer.SaveAsync(
savedFileName,
stream,
true
);
// Dosya bilgisini DTO olarak ekle
fileDtos.Add(new ActivityFileDto
{
FileName = file.FileName,
FileType = file.ContentType,
FileSize = file.ContentLength ?? 0,
SavedFileName = savedFileName
});
}
}
input.FilesJson = JsonSerializer.Serialize(fileDtos);
var dto = await base.CreateAsync(input);
dto.CreateUserName = CurrentUser.UserName;
return dto;
}
public override async Task DeleteAsync(Guid id)
{
// Önce entity'i alıyoruz
var activity = await Repository.GetAsync(id);
// if (!string.IsNullOrEmpty(activity.FilesJson))
// {
// try
// {
// // FilesJson içindeki dosya bilgilerini deserialize ediyoruz
// var files = JsonSerializer.Deserialize<List<ActivityFileDto>>(activity.FilesJson);
// if (files != null)
// {
// foreach (var file in files)
// {
// // Blob storage'dan sil
// await _activityBlobContainer.DeleteAsync(file.SavedFileName, cancellationToken: default);
// }
// }
// }
// catch (Exception ex)
// {
// // Opsiyonel: silme sırasında hata loglayabilirsin
// Logger.LogWarning(ex, "Blob dosyaları silinirken hata oluştu.");
// }
// }
// Sonra veritabanındaki kaydı sil
await base.DeleteAsync(id);
} }
} }

View file

@ -1,11 +1,21 @@
import { AuditedEntityDto } from "../abp"; import { FullAuditedEntityDto } from '../abp'
export interface ActivityDto extends AuditedEntityDto { export interface ActivityDto extends FullAuditedEntityDto {
tenantId?: string; tenantId?: string
entityName: string; entityName: string
entityId: string; entityId: string
type: string; type: string
subject: string; subject: string
content: string; content: string
files: File[]; filesJson?: string
createUserName: string
files: File[]
} }
export interface ActivityFileDto {
fileName: string
fileType: string
fileSize: number
savedFileName: string
fileBase64: string
}

View file

@ -1,5 +1,5 @@
import { PagedResultDto } from '@/proxy' import { PagedResultDto } from '@/proxy'
import { ActivityDto } from '@/proxy/activity/models' import { ActivityDto, ActivityFileDto } from '@/proxy/activity/models'
import apiService from '@/services/api.service' import apiService from '@/services/api.service'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
@ -21,35 +21,13 @@ class ActivityService {
return response.data return response.data
} }
async create(data: ActivityDto): Promise<any> { async create(data: FormData): Promise<any> {
try { try {
const formData = new FormData()
formData.append('entityName', data.entityName)
formData.append('entityId', data.entityId)
formData.append('type', data.type)
formData.append('subject', data.subject)
formData.append('content', data.content)
// Ensure files are actual File objects, not blob URLs
if (data.files && data.files.length > 0) {
for (const file of data.files) {
// Ensure file is a File object (in case it's a Blob URL, you need to get the actual file)
if (file instanceof File) {
formData.append('files', file)
}
}
}
console.log('FormData:', formData)
// Make the POST request
const response = await apiService.fetchData({ const response = await apiService.fetchData({
url: '/api/app/activity', url: '/api/app/activity',
method: 'POST', method: 'POST',
data: formData, data, // FormData olduğu için headers belirtmeye gerek yok
// Don't manually set the Content-Type header; let the browser handle it
}) })
return response.data return response.data
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@ -77,6 +55,27 @@ class ActivityService {
method: 'DELETE', method: 'DELETE',
}) })
} }
async downloadFile(savedFileName: string, fileName: string, fileType: string) {
const response = await apiService.fetchData<ActivityFileDto>({
url: `/api/app/activity/download?savedFileName=${savedFileName}`,
method: 'GET',
})
const base64 = response.data.fileBase64
const binary = atob(base64) // base64 -> binary string
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
const blob = new Blob([bytes], { type: response.data.fileType })
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = response.data.fileName || fileName
link.click()
}
} }
export const activityService = new ActivityService() export const activityService = new ActivityService()

View file

@ -1,15 +1,9 @@
import React from 'react' import React from 'react'
import { import { FaStickyNote, FaEnvelope, FaTrash, FaDownload, FaClock, FaPaperclip } from 'react-icons/fa'
FaStickyNote, import { Avatar, Button } from '@/components/ui'
FaFileAlt,
FaEnvelope,
FaTrash,
FaDownload,
FaUser,
FaClock,
} from 'react-icons/fa'
import { Button } from '@/components/ui'
import { ActivityDto } from '@/proxy/activity/models' import { ActivityDto } from '@/proxy/activity/models'
import { AVATAR_URL } from '@/constants/app.constant'
import { useStoreState } from '@/store/store'
interface ActivityListProps { interface ActivityListProps {
activities: ActivityDto[] activities: ActivityDto[]
@ -22,105 +16,125 @@ export const ActivityList: React.FC<ActivityListProps> = ({
onDeleteActivity, onDeleteActivity,
onDownloadFile, onDownloadFile,
}) => { }) => {
const getActivityIcon = (type: string) => { const user = useStoreState((state) => state.auth.user)
const getActivityStyle = (type: string) => {
switch (type) { switch (type) {
case 'note': case 'note':
return <FaStickyNote className="text-yellow-500" /> return {
icon: <FaStickyNote className="text-yellow-500" />,
border: 'border-yellow-400',
}
case 'message': case 'message':
return <FaEnvelope className="text-green-500" /> return {
icon: <FaEnvelope className="text-green-500" />,
border: 'border-green-400',
}
default: default:
return <FaStickyNote className="text-gray-500" /> return {
icon: <FaStickyNote className="text-gray-400" />,
border: 'border-gray-300',
}
} }
} }
// const handleDelete = (activity: ActivityDto) => { if (activities.length === 0)
// onDeleteActivity?.(activity)
// }
const handleDownloadFile = (fileData: any) => {
onDownloadFile?.(fileData)
}
if (activities.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500"> <div className="flex flex-col items-center justify-center h-32 text-gray-500">
<FaStickyNote className="text-4xl mb-2 opacity-50" /> <FaStickyNote className="text-4xl mb-2 opacity-50" />
<p className="text-sm">Henüz hiçbir aktivite bulunmuyor</p> <p className="text-sm">Henüz hiçbir aktivite bulunmuyor</p>
</div> </div>
) )
}
return ( return (
<div className="space-y-3"> <div className="relative">
{activities.map((activity) => ( <div className="space-y-5 ml-5">
<div {activities.map((activity, index) => {
key={activity.id} const files = activity.filesJson ? JSON.parse(activity.filesJson) : []
className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow" const creationDate = activity.creationTime ? new Date(activity.creationTime) : null
> const { icon, border } = getActivityStyle(activity.type)
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">{getActivityIcon(activity.type)}</div>
<div className="flex-1 min-w-0"> return (
<div className="flex items-center gap-1 font-semibold mb-1"> <div
<FaUser className="text-xs" /> key={activity.id || index}
<span>{activity.creatorId}</span> className={`relative bg-white border-l-4 rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 ${border}`}
>
{/* Timeline Düğmesi */}
<div className="absolute -left-7 top-4 bg-white rounded-full border-2 border-gray-300 p-2">
{icon}
</div> </div>
{activity.subject && ( <div className="p-4">
<p className="text-sm font-medium text-gray-800 mb-1 break-words"> {/* Header */}
{activity.subject} <div className="flex justify-between items-start">
</p> <div>
)} <div className="flex items-center gap-1 text-sm font-semibold text-gray-800">
<Avatar
size={25}
shape="circle"
src={AVATAR_URL(activity.creatorId, activity.tenantId)}
/>
{activity.createUserName}
</div>
{creationDate && (
<div className="flex items-center gap-1 text-xs text-gray-400 mt-1 ml-1">
<FaClock /> {creationDate.toLocaleString()}
</div>
)}
</div>
{activity.content && ( {/* Sil butonu */}
<p className="text-sm text-gray-700 mb-2 break-words">{activity.content}</p> {user?.id === activity.creatorId && (
)} <Button
variant="plain"
size="xs"
onClick={() => onDeleteActivity?.(activity.id as string)}
title="Sil"
className="text-red-400 hover:text-red-600"
>
<FaTrash />
</Button>
)}
</div>
{/* Note tipinde dosyaları göster */} {/* Body */}
{/* {activity.type === 'note' && activity.data && (activity.data as any).attachedFiles?.length > 0 && ( <div className="mt-3 ml-1">
<div className="mt-2 mb-2"> {activity.subject && (
{((activity.data as any).attachedFiles || []).map((file: any, index: number) => ( <h4 className="text-sm font-bold text-gray-900 mb-1">{activity.subject}</h4>
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded border"> )}
<div className="flex items-center gap-2"> {activity.content && (
<FaFileAlt className="text-blue-500 text-sm" /> <div dangerouslySetInnerHTML={{ __html: activity.content }} />
<span className="text-xs font-medium">{file.fileName}</span> )}
<span className="text-xs text-gray-500">({(file.fileSize / 1024).toFixed(1)} KB)</span> </div>
</div>
{/* Files */}
{files.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{files.map((file: any, index: number) => (
<div
key={index}
className="group flex items-center gap-2 bg-gray-50 border border-gray-200 px-2 py-1 rounded-md text-xs text-gray-600 hover:bg-gray-100 transition"
>
<FaPaperclip className="text-blue-500" />
<span className="truncate max-w-[150px]">{file.FileName}</span>
<Button <Button
variant="plain" variant="plain"
size="xs" size="xs"
onClick={() => handleDownloadFile(file)} onClick={() => onDownloadFile?.(file)}
title="İndir" title="İndir"
className="text-blue-500 hover:text-blue-700" className="text-blue-500 hover:text-blue-700 ml-1"
> >
<FaDownload /> <FaDownload />
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
)} */} )}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-gray-500">
<FaClock />
{activity.creationTime?.toLocaleString()}
</div>
<div className="flex items-center gap-1">
<Button
variant="plain"
size="xs"
// onClick={() => handleDelete(activity)}
title="Sil"
>
<FaTrash className="text-red-500" />
</Button>
</div>
</div> </div>
</div> </div>
</div> )
</div> })}
))} </div>
</div> </div>
) )
} }

View file

@ -1,174 +1,265 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Button, Input, Dialog, Select, FormContainer, FormItem, Upload } from '@/components/ui' import { Button, Input, Dialog, FormContainer, FormItem, Upload, Radio } from '@/components/ui'
import { FaFileUpload, FaStickyNote, FaPlus, FaTrash, FaPaperclip } from 'react-icons/fa' import { FaFileAlt, FaFileUpload, FaPlus, FaTrash } from 'react-icons/fa'
import { Field, Form, Formik } from 'formik' import { Field, FieldProps, Form, Formik } from 'formik'
import { SelectBoxOption } from '@/shared/types'
import * as Yup from 'yup' import * as Yup from 'yup'
import { activityService } from '@/services/activity.service' import { activityService } from '@/services/activity.service'
import {
sizeValues,
fontSizeOptions,
fontValues,
fontFamilyOptions,
headerValues,
headerOptions,
} from '@/proxy/reports/data'
import { HtmlEditor, ImageUpload, Item, MediaResizing, Toolbar } from 'devextreme-react/html-editor'
// Validation schema
const validationSchema = Yup.object({ const validationSchema = Yup.object({
type: Yup.string().required('Not tipi zorunludur'), type: Yup.string().required('Not tipi zorunludur'),
subject: Yup.string().required('Konu zorunludur'), subject: Yup.string().required('Konu zorunludur'),
content: Yup.string().required('İçerik zorunludur'), content: Yup.string().required('İçerik zorunludur'),
}) })
interface AddContentModalProps { interface ActivityModalProps {
entityName: string entityName: string
entityId: string entityId: string
isOpen: boolean isOpen: boolean
onClose: () => void onClose: () => void
onActivityAdded?: (activity: any) => void
} }
export const AddContentModal: React.FC<AddContentModalProps> = ({ export const ActivityModal: React.FC<ActivityModalProps> = ({
entityName, entityName,
entityId, entityId,
isOpen, isOpen,
onClose, onClose,
onActivityAdded,
}) => { }) => {
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [files, setFiles] = useState<string[] | undefined>() const [fileList, setFileList] = useState<File[]>([])
const types: SelectBoxOption[] = [ const types = [
{ value: 'note', label: 'Not' }, { value: 'note', label: 'Not' },
{ value: 'message', label: 'Mesaj' }, { value: 'message', label: 'Mesaj' },
{ value: 'activity', label: 'Aktivite' }, { value: 'activity', label: 'Aktivite' },
] ]
// onSaveContent güncelleme
const handleSave = async (values: any) => { const handleSave = async (values: any) => {
setUploading(true) setUploading(true)
try { try {
// API çağrısı const formData = new FormData()
await activityService.create({ ...values, entityName, entityId, files }) formData.append('entityName', entityName)
formData.append('entityId', entityId)
formData.append('type', values.type)
formData.append('subject', values.subject)
formData.append('content', values.content)
fileList.forEach((file) => formData.append('Files', file))
//onClose() // Modal'ı kapat const createdActivity = await activityService.create(formData)
} catch (error) { if (onActivityAdded) onActivityAdded(createdActivity)
console.error('Save failed:', error) setFileList([])
onClose()
} catch (err) {
console.error(err)
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
const beforeUpload = (files: FileList | null, fileList: File[]) => { const beforeUpload = (files: FileList | null) => {
let valid: string | boolean = true if (!files) return true
for (const f of Array.from(files)) {
const maxFileSize = 2000000 if (f.size > 2 * 1024 * 1024) return 'En fazla 2MB dosya yükleyebilirsiniz'
if (fileList.length >= 1) {
return `Sadece bir dosya seçebilirsiniz`
} }
return true
if (files) {
for (const f of files) {
if (f.size >= maxFileSize) {
valid = 'En fazla 2mb dosya yükleyebilirsiniz'
}
}
}
return valid
} }
const onChooseImage = async (file: File[]) => { const removeFile = (index: number) => {
if (file.length === 0) { setFileList((prev) => prev.filter((_, i) => i !== index))
setFiles(undefined)
return
}
setFiles(file.map((f) => URL.createObjectURL(f)))
} }
return ( return (
<Dialog isOpen={isOpen} onClose={onClose}> <Dialog isOpen={isOpen} onClose={onClose} width={700}>
<div className="p-2 w-full mx-auto"> <div className="p-2 w-full mx-auto">
<div className="flex items-center justify-between mb-6"> {/* Başlık */}
<div className="flex items-center justify-between mb-5">
<h3 className="text-xl font-semibold flex items-center gap-3"> <h3 className="text-xl font-semibold flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-full"> <div className="p-2 bg-purple-100 rounded-full">
<FaPlus className="text-purple-600 text-lg" /> <FaPlus className="text-purple-600 text-lg" />
</div> </div>
Not Ekle Not / Aktivite Ekle
</h3> </h3>
</div> </div>
<Formik <Formik
initialValues={{ initialValues={{ type: 'note', subject: '', content: '' }}
type: 'note',
subject: '',
content: '',
}}
validationSchema={validationSchema} validationSchema={validationSchema}
onSubmit={handleSave} onSubmit={handleSave}
> >
{({ values, touched, errors, setFieldValue, isSubmitting }) => ( {({ values, touched, errors, setFieldValue, isSubmitting }) => (
<Form> <Form>
<FormContainer size="sm"> <FormContainer size="sm">
{/* Not Tipi */} {/* NOT TİPİ */}
<FormItem <FormItem
label="Not Tipi"
invalid={!!(errors.type && touched.type)} invalid={!!(errors.type && touched.type)}
errorMessage={errors.type} errorMessage={errors.type}
> >
<Field name="type"> <div className="flex gap-4">
{({ field }: any) => ( {types.map((t) => (
<Select <label
{...field} key={t.value}
value={types.find((t) => t.value === values.type)} className={`flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer transition-all duration-200 ${
onChange={(selected: any) => setFieldValue('type', selected.value)} values.type === t.value
options={types} ? 'border-purple-500 bg-purple-50 text-purple-700'
isSearchable={false} : 'border-gray-300 hover:border-purple-400'
isClearable }`}
/> >
)} <Radio
</Field> value={t.value}
checked={values.type === t.value}
onChange={() => setFieldValue('type', t.value)}
/>
{t.label}
</label>
))}
</div>
</FormItem> </FormItem>
{/* Konu */} {/* KONUSU */}
<FormItem <FormItem
label="Konu" label="Konu"
invalid={!!(errors.subject && touched.subject)} invalid={!!(errors.subject && touched.subject)}
errorMessage={errors.subject} errorMessage={errors.subject}
>
<Field type="text" name="subject" component={Input} />
</FormItem>
{/* Not İçeriği */}
<FormItem
label="Not İçeriği"
invalid={!!(errors.content && touched.content)}
errorMessage={errors.content}
> >
<Field <Field
name="content" type="text"
render={({ field }: any) => ( name="subject"
<textarea as={Input}
{...field} placeholder="Kısa bir başlık girin..."
placeholder="Notunuzu buraya yazın..."
className="w-full p-2 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
rows={4}
/>
)}
/> />
</FormItem> </FormItem>
{/* Dosya Yükleme */} {/* İÇERİK */}
<FormItem
label="İçerik"
asterisk
invalid={!!(errors.content && touched.content)}
errorMessage={errors.content}
>
<Field name="content">
{({ field }: FieldProps) => (
<HtmlEditor
value={field.value}
onValueChanged={(e) => setFieldValue('content', e.value)}
height={220}
placeholder="Notunuzu buraya yazın..."
>
<MediaResizing enabled={true} />
<ImageUpload fileUploadMode="base64" />
<Toolbar multiline>
<Item name="undo" />
<Item name="redo" />
<Item name="separator" />
<Item name="size" acceptedValues={sizeValues} options={fontSizeOptions} />
<Item
name="font"
acceptedValues={fontValues}
options={fontFamilyOptions}
/>
<Item name="separator" />
<Item name="bold" />
<Item name="italic" />
<Item name="underline" />
<Item name="strike" />
<Item name="separator" />
<Item name="orderedList" />
<Item name="bulletList" />
<Item name="separator" />
<Item
name="header"
acceptedValues={headerValues}
options={headerOptions}
/>
<Item name="separator" />
<Item name="color" />
<Item name="background" />
<Item name="separator" />
<Item name="alignLeft" />
<Item name="alignCenter" />
<Item name="alignRight" />
<Item name="alignJustify" />
<Item name="separator" />
<Item name="link" />
<Item name="image" />
<Item name="separator" />
<Item name="clear" />
</Toolbar>
</HtmlEditor>
)}
</Field>
</FormItem>
{/* DOSYA YÜKLEME */}
<FormItem label="Dosya Ekle"> <FormItem label="Dosya Ekle">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-purple-400 transition-colors duration-200"> <div className="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-purple-400 transition-colors duration-200">
<Upload <Upload
className="cursor-pointer" className="cursor-pointer"
showList={false} showList={false}
multiple={false} multiple
uploadLimit={1} fileList={fileList}
beforeUpload={beforeUpload} beforeUpload={beforeUpload}
onChange={onChooseImage} onChange={(files: File[]) => {
setFileList((prev) => [
...prev,
...files.filter(
(f) => !prev.some((p) => p.name === f.name && p.size === f.size),
),
])
}}
> >
<Button icon={<FaFileUpload />} type="button"></Button> <Button
icon={<FaFileUpload />}
type="button"
variant="twoTone"
className="flex items-center justify-center mx-auto"
>
Dosya Yükle
</Button>
</Upload> </Upload>
{fileList.length > 0 && (
<div className="mt-2 max-h-28 overflow-y-auto border rounded-md bg-gray-50 text-left">
{fileList.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 border-b last:border-none"
>
<div className="flex items-center gap-2 truncate">
<FaFileAlt className="text-blue-500 text-sm flex-shrink-0" />
<span className="text-sm truncate">{file.name}</span>
<span className="text-xs text-gray-500">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
<Button
variant="plain"
size="xs"
type="button"
onClick={() => removeFile(index)}
className="text-red-500 hover:text-red-700"
>
<FaTrash />
</Button>
</div>
))}
</div>
)}
</div> </div>
</FormItem> </FormItem>
</FormContainer> </FormContainer>
<div className="mt-4 flex justify-between items-center pt-4 border-t border-gray-200"> {/* ALT BUTONLAR */}
<div className="mt-5 flex justify-between items-center pt-4 border-t border-gray-200">
<Button variant="default" size="md" onClick={onClose} disabled={uploading}> <Button variant="default" size="md" onClick={onClose} disabled={uploading}>
İptal İptal
</Button> </Button>

View file

@ -1,8 +1,16 @@
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } from 'react'
import { AddContentModal } from './ActivityModal' import { ActivityModal } from './ActivityModal'
import { ActivityList } from './ActivityList' import { ActivityList } from './ActivityList'
import { Button, Badge } from '@/components/ui' import { Button, Badge } from '@/components/ui'
import { FaChevronLeft, FaChevronRight, FaPlus, FaTimes, FaGripVertical } from 'react-icons/fa' import {
FaChevronLeft,
FaChevronRight,
FaPlus,
FaTimes,
FaGripVertical,
FaChevronUp,
FaChevronDown,
} from 'react-icons/fa'
import { activityService } from '@/services/activity.service' import { activityService } from '@/services/activity.service'
import { ActivityDto } from '@/proxy/activity/models' import { ActivityDto } from '@/proxy/activity/models'
@ -19,177 +27,116 @@ export const ActivityPanel: React.FC<ActivityPanelProps> = ({
isVisible, isVisible,
onToggle, onToggle,
}) => { }) => {
const [showAddContent, setShowAddContent] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [activities, setActivities] = useState<ActivityDto[]>([])
// Draggable button state const [buttonPosition, setButtonPosition] = useState({ top: '75%' })
const [buttonPosition, setButtonPosition] = useState({ top: '50%' })
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ y: 0, startTop: 0 }) const [dragStart, setDragStart] = useState({ y: 0, startTop: 0 })
const [activities, setActivities] = useState<ActivityDto[]>([])
const buttonRef = useRef<HTMLDivElement>(null) const buttonRef = useRef<HTMLDivElement>(null)
const [showEntityInfo, setShowEntityInfo] = useState(false)
// Fetch activities
const fetchActivities = async () => {
try {
const res = await activityService.getList({ entityName, entityId })
if (res?.items) setActivities(res.items)
} catch (err) {
console.error(err)
}
}
useEffect(() => { useEffect(() => {
const fetchActivities = async () => { if (isVisible) fetchActivities()
// Simulate API call to fetch activities
// Gerçek implementasyonda API'dan veri çekilecek
const fetchedActivities = await activityService.getList()
if (fetchedActivities && fetchedActivities.items) {
setActivities(fetchedActivities.items)
}
}
fetchActivities()
}, [isVisible]) }, [isVisible])
const handleDownloadFile = (fileData: any) => { const handleDownloadFile = async (fileData: any) => {
// Simulate file download - gerçek implementasyonda API'dan dosya indirme yapılacak if (!fileData?.SavedFileName) return
const link = document.createElement('a') try {
link.href = '#' // gerçek dosya URL'i buraya gelecek await activityService.downloadFile(
link.download = fileData.fileName fileData.SavedFileName,
link.click() fileData.FileName,
fileData.FileType,
)
} catch (err) {
console.error('Dosya indirilemedi', err)
}
}
const handleDeleteActivity = async (activityId: string) => {
if (!confirm('Bu aktiviteyi silmek istediğinize emin misiniz?')) return
try {
await activityService.delete(activityId)
setActivities((prev) => prev.filter((a) => a.id !== activityId))
} catch (err) {
console.error(err)
}
} }
const getTotalCount = () => activities.length const getTotalCount = () => activities.length
// Mouse drag handlers // Draggable button handlers
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (!buttonRef.current) return if (!buttonRef.current) return
e.preventDefault() e.preventDefault()
e.stopPropagation()
setIsDragging(true) setIsDragging(true)
const rect = buttonRef.current.getBoundingClientRect() setDragStart({ y: e.clientY, startTop: buttonRef.current.getBoundingClientRect().top })
const startTop = rect.top
setDragStart({
y: e.clientY,
startTop: startTop,
})
} }
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !buttonRef.current) return if (!isDragging || !buttonRef.current) return
e.preventDefault() e.preventDefault()
e.stopPropagation()
const deltaY = e.clientY - dragStart.y const deltaY = e.clientY - dragStart.y
const newTop = dragStart.startTop + deltaY const newTop = dragStart.startTop + deltaY
// Calculate percentage based on viewport
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight
const buttonHeight = buttonRef.current.offsetHeight const buttonHeight = buttonRef.current.offsetHeight
const constrainedTop = Math.max(0, Math.min(viewportHeight - buttonHeight, newTop))
// Constrain within viewport bounds
const minTop = 0
const maxTop = viewportHeight - buttonHeight
const constrainedTop = Math.max(minTop, Math.min(maxTop, newTop))
// Convert to percentage
const topPercentage = (constrainedTop / viewportHeight) * 100 const topPercentage = (constrainedTop / viewportHeight) * 100
setButtonPosition({ top: `${topPercentage}%` }) setButtonPosition({ top: `${topPercentage}%` })
} }
const handleMouseUp = () => { const handleMouseUp = () => setIsDragging(false)
setIsDragging(false)
}
// Add event listeners for mouse move and up
useEffect(() => { useEffect(() => {
if (isDragging) { if (isDragging) {
// Disable text selection during drag
document.body.style.userSelect = 'none' document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
document.addEventListener('mousemove', handleMouseMove, { passive: false }) document.addEventListener('mousemove', handleMouseMove, { passive: false })
document.addEventListener('mouseup', handleMouseUp) document.addEventListener('mouseup', handleMouseUp)
return () => { return () => {
// Re-enable text selection
document.body.style.userSelect = '' document.body.style.userSelect = ''
document.body.style.webkitUserSelect = ''
document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('mouseup', handleMouseUp)
} }
} }
}, [isDragging, dragStart]) }, [isDragging, dragStart])
// Load saved position from localStorage if (!entityName || !entityId) return null
useEffect(() => {
const savedPosition = localStorage.getItem('activityPanelButtonPosition')
if (savedPosition) {
try {
const position = JSON.parse(savedPosition)
setButtonPosition(position)
} catch (error) {
console.error('Failed to load button position:', error)
}
}
}, [])
// Save position to localStorage
useEffect(() => {
localStorage.setItem('activityPanelButtonPosition', JSON.stringify(buttonPosition))
}, [buttonPosition])
if (!entityName || !entityId) {
return null
}
// // Example onSaveContent function
// const onSaveContent = async (
// entityName: string,
// entityId: string,
// type: string,
// subject: string,
// content: string,
// files: File[],
// ) => {
// try {
// await activityService.create({ entityName, entityId, type, subject, content, files } as any)
// } catch (error) {
// console.error('Error saving content:', error)
// throw error
// }
// }
return ( return (
<> <>
{/* Toggle Button - Draggable */} {/* Draggable toggle button */}
<div <div
ref={buttonRef} ref={buttonRef}
className="fixed right-0 z-40" className="fixed right-0 z-40"
style={{ style={{ top: buttonPosition.top, transform: 'translateY(-50%)' }}
top: buttonPosition.top,
transform: 'translateY(-50%)',
}}
> >
<div className="group relative"> <div className="group relative">
{/* Drag Handle */}
<div <div
className={`absolute -left-2 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`} className={`absolute -left-2 top-1/2 transform -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none ${isDragging ? 'cursor-grabbing' : 'cursor-grab'}`}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
style={{ userSelect: 'none' }}
> >
<div className="bg-gray-600 text-white p-1 rounded-l text-xs"> <div className="bg-gray-600 text-white p-1 rounded-l text-xs">
<FaGripVertical /> <FaGripVertical />
</div> </div>
</div> </div>
{/* Main Button */}
<Button <Button
variant="solid" variant="solid"
size="sm" size="sm"
shape="none"
className="!rounded-l-full !rounded-r-none" className="!rounded-l-full !rounded-r-none"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onToggle() onToggle()
}} }}
title={isVisible ? 'Aktivite panelini kapat' : 'Aktivite panelini aç'} title={isVisible ? 'Paneli kapat' : 'Paneli aç'}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isVisible ? <FaChevronRight /> : <FaChevronLeft />} {isVisible ? <FaChevronRight /> : <FaChevronLeft />}
@ -199,67 +146,84 @@ export const ActivityPanel: React.FC<ActivityPanelProps> = ({
</div> </div>
</div> </div>
{/* Overlay - Click outside to close */} {/* Overlay */}
{isVisible && ( {isVisible && (
<div className="fixed inset-0 bg-black bg-opacity-25 z-20" onClick={onToggle} /> <div className="fixed inset-0 bg-black bg-opacity-25 z-20" onClick={onToggle} />
)} )}
{/* Activity Panel */} {/* Panel */}
<div <div
className={`fixed right-0 top-0 h-full bg-white border-l border-gray-300 shadow-xl transform transition-transform duration-300 ease-in-out z-30 ${ className={`fixed right-0 top-0 h-full bg-white border-l border-gray-300 shadow-xl transform transition-transform duration-300 ease-in-out z-30 ${isVisible ? 'translate-x-0' : 'translate-x-full'}`}
isVisible ? 'translate-x-0' : 'translate-x-full' style={{ width: '450px' }}
}`}
style={{ width: '400px' }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200 bg-gray-50"> <div className="p-4 border-b border-gray-200 bg-gray-50">
{/* Üst Satır: Başlık, Kayıt Bilgisi Toggle ve Kapat Butonu */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-800">Aktiviteler</h3> <h3 className="text-lg font-semibold text-gray-800">Aktiviteler</h3>
<Button variant="plain" size="xs" onClick={onToggle}>
<FaTimes /> <div className="flex items-center gap-3">
</Button> {/* 👇 Kayıt Bilgisi Aç/Kapa Butonu */}
<button
onClick={() => setShowEntityInfo((prev) => !prev)}
className="flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800 cursor-pointer select-none"
title="Kayıt Bilgisi"
>
{showEntityInfo ? <FaChevronUp /> : <FaChevronDown />}
</button>
{/* Kapat Butonu */}
<Button variant="plain" size="xs" onClick={onToggle} title="Paneli kapat">
<FaTimes />
</Button>
</div>
</div> </div>
<div className="text-sm text-gray-600 mb-3 flex items-center gap-2"> {/* 👇 Açılır Kayıt Bilgisi İçeriği */}
<span className="font-medium">{entityName}</span> <div
<code className="bg-gray-100 px-2 rounded text-gray-800 text-xs font-mono"> className={`transition-all duration-300 overflow-hidden ${
<Badge className="bg-blue-100 text-blue-600" content={entityId} /> showEntityInfo ? 'max-h-20 mt-2 opacity-100' : 'max-h-0 opacity-0'
</code> }`}
>
<div className="flex items-center gap-1 text-sm text-gray-700">
<span className="font-medium">{entityName}</span>
<code className="bg-gray-100 px-2 rounded text-gray-800 text-xs font-mono">
<Badge className="bg-blue-100 text-blue-600" content={entityId} />
</code>
</div>
</div> </div>
{/* Action Buttons */} {/* Alt buton: Not Ekle */}
<div className="flex gap-2 mb-3"> <div className="flex gap-2 mt-3">
<Button <Button
variant="solid" variant="solid"
size="xs" size="xs"
onClick={() => setShowAddContent(true)} onClick={() => setShowAddModal(true)}
className="flex justify-center items-center py-4 w-full" className="flex justify-center items-center py-4 w-full"
> >
<FaPlus className="mr-1" /> <FaPlus className="mr-1" /> Not Ekle
Not Ekle
</Button> </Button>
</div> </div>
</div> </div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<ActivityList <ActivityList
activities={activities} activities={activities}
// onDeleteActivity={deleteActivity} onDeleteActivity={handleDeleteActivity}
onDownloadFile={handleDownloadFile} onDownloadFile={handleDownloadFile}
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Modals */} {/* Modal */}
<AddContentModal <ActivityModal
entityName={entityName} entityName={entityName}
entityId={entityId} entityId={entityId}
isOpen={showAddContent} isOpen={showAddModal}
onClose={() => setShowAddContent(false)} onClose={() => setShowAddModal(false)}
onActivityAdded={(act) => setActivities((prev) => [act, ...prev])}
/> />
</> </>
) )