Show Activity komponenti oluşturuldu.

Şu an static veriler üzerinde çalışıyor.
This commit is contained in:
Sedat ÖZTÜRK 2025-10-13 15:29:49 +03:00
parent 8cdeb0a21f
commit 1faad17e7c
16 changed files with 1441 additions and 100 deletions

View file

@ -305,6 +305,9 @@ public class GridOptionsDto : AuditedEntityDto<Guid>
public string ListFormType { get; set; } = ListFormTypeEnum.List;
public bool IsSubForm { get; set; } = false;
/// <summary>Bu listform show activity gösterilsin mi?</summary>
public bool ShowActivity { get; set; } = true;
[JsonIgnore]
public string SubFormsJson { get; set; } // Cagrilacak ListFormlar
public SubFormDto[] SubFormsDto

View file

@ -64,6 +64,7 @@ public class ListFormsAppService : CrudAppService<
item.Height = input.Height;
item.Description = input.Description;
item.IsSubForm = input.IsSubForm;
item.ShowActivity = input.ShowActivity;
item.ListFormType = input.ListFormType;
item.LayoutJson = JsonSerializer.Serialize(input.LayoutDto);

View file

@ -5281,6 +5281,12 @@
"en": "Is Sub",
"tr": "Is Sub"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.ShowActivity",
"en": "Show Activity",
"tr": "Etkinliği Göster"
},
{
"resourceName": "Platform",
"key": "ListForms.ListFormEdit.ListFormType",

View file

@ -115,6 +115,9 @@ public class ListForm : FullAuditedEntity<Guid>
/// <summary>Bu listform sub olarak mı kullanılacak</summary>
public bool IsSubForm { get; set; }
/// <summary>Bu listform show activity gösterilsin mi?</summary>
public bool ShowActivity { get; set; }
/// <summary>Bu listform'un sub listformlarının listesi ve ilişkileri</summary>
public string SubFormsJson { get; set; }

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Kurs.Platform.Migrations
{
[DbContext(typeof(PlatformDbContext))]
[Migration("20251013062624_Initial")]
[Migration("20251013115205_Initial")]
partial class Initial
{
/// <inheritdoc />
@ -3841,6 +3841,9 @@ namespace Kurs.Platform.Migrations
b.Property<string>("SeriesJson")
.HasColumnType("text");
b.Property<bool>("ShowActivity")
.HasColumnType("bit");
b.Property<string>("SizeJson")
.HasColumnType("text");

View file

@ -1628,6 +1628,7 @@ namespace Kurs.Platform.Migrations
IsOrganizationUnit = table.Column<bool>(type: "bit", nullable: false),
ListFormType = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
IsSubForm = table.Column<bool>(type: "bit", nullable: false),
ShowActivity = table.Column<bool>(type: "bit", nullable: false),
SubFormsJson = table.Column<string>(type: "text", nullable: true),
WidgetsJson = table.Column<string>(type: "text", nullable: true),
ExtraFilterJson = table.Column<string>(type: "text", nullable: true),

View file

@ -3838,6 +3838,9 @@ namespace Kurs.Platform.Migrations
b.Property<string>("SeriesJson")
.HasColumnType("text");
b.Property<bool>("ShowActivity")
.HasColumnType("bit");
b.Property<string>("SizeJson")
.HasColumnType("text");

View file

@ -495,6 +495,7 @@ export interface GridOptionsDto extends AuditedEntityDto<string> {
isOrganizationUnit: boolean
listFormType: string
isSubForm: boolean
showActivity: boolean
subFormsJson?: string
subFormsDto: SubFormDto[]
extraFilterJson?: string

View file

@ -0,0 +1,46 @@
export interface ActivityItem {
id: string
type: 'note' | 'message'
subject: string
content: string
recipientUserName?: string
creatorId: string
creationTime: Date
data?: NoteData | MessageData
}
export interface NoteData {
id?: string
entityName: string
entityId: string
subject: string
content: string
creatorId?: string
creationTime?: Date
attachedFiles?: FileData[]
}
export interface FileData {
id?: string
entityName: string
entityId: string
fileName: string
fileSize: number
fileType: string
filePath: string
creatorId?: string
creationTime?: Date
}
export interface MessageData {
id?: string
entityName: string
entityId: string
recipientUserId: string
recipientUserName: string
subject: string
content: string
creatorId?: string
creationTime?: Date
isRead?: boolean
}

View file

@ -15,10 +15,13 @@ import { IdentityRoleDto, IdentityUserDto } from '@/proxy/admin/models'
const schema = Yup.object().shape({
cultureName: Yup.string().required('Culture Name Required'),
listFormType: Yup.string().required('List Form Type Required'),
title: Yup.string().required('Title Required'),
name: Yup.string(),
pageSize: Yup.number(),
description: Yup.string(),
pageSize: Yup.number(),
isSubForm: Yup.boolean(),
showActivity: Yup.boolean(),
layoutDto: Yup.object().shape({
grid: Yup.boolean(),
card: Yup.boolean(),
@ -222,6 +225,18 @@ function FormTabDetails(
/>
</FormItem>
<FormItem
label={translate('::ListForms.ListFormEdit.ShowActivity')}
invalid={errors.showActivity && touched.showActivity}
errorMessage={errors.showActivity}
>
<Field
name="showActivity"
placeholder={translate('::ListForms.ListFormEdit.ShowActivity')}
component={Checkbox}
/>
</FormItem>
<div className="flex gap-2">
<FormItem
label={translate('::ListForms.ListFormEdit.DetailsWidth')}

View file

@ -0,0 +1,156 @@
import React from 'react'
import {
FaStickyNote,
FaFileAlt,
FaEnvelope,
FaTrash,
FaDownload,
FaUser,
FaClock,
} from 'react-icons/fa'
import { Button } from '@/components/ui'
import { ActivityItem } from '@/proxy/formActivity/models'
interface ActivityListProps {
activities: ActivityItem[]
onDeleteNote?: (noteId: string) => void
onDeleteFile?: (fileId: string) => void
onDeleteMessage?: (messageId: string) => void
onDownloadFile?: (fileData: any) => void
}
export const ActivityList: React.FC<ActivityListProps> = ({
activities,
onDeleteNote,
onDeleteFile,
onDeleteMessage,
onDownloadFile,
}) => {
const formatDate = (date: Date) => {
return new Intl.DateTimeFormat('tr-TR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date))
}
const getActivityIcon = (type: string) => {
switch (type) {
case 'note':
return <FaStickyNote className="text-yellow-500" />
case 'message':
return <FaEnvelope className="text-green-500" />
default:
return <FaStickyNote className="text-gray-500" />
}
}
const handleDelete = (activity: ActivityItem) => {
switch (activity.type) {
case 'note':
onDeleteNote?.(activity.id)
break
case 'message':
onDeleteMessage?.(activity.id)
break
}
}
const handleDownloadFile = (fileData: any) => {
onDownloadFile?.(fileData)
}
if (activities.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-32 text-gray-500">
<FaStickyNote className="text-4xl mb-2 opacity-50" />
<p className="text-sm">Henüz hiçbir aktivite bulunmuyor</p>
</div>
)
}
return (
<div className="space-y-3">
{activities.map((activity) => (
<div
key={activity.id}
className="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow"
>
<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">
<div className="flex items-center gap-1 font-semibold mb-1">
<FaUser className="text-xs" />
<span>{activity.creatorId}</span>
{activity.recipientUserName && (
<>
<span></span>
<FaUser className="text-xs" />
<span>{activity.recipientUserName}</span>
</>
)}
</div>
{activity.subject && (
<p className="text-sm font-medium text-gray-800 mb-1 break-words">{activity.subject}</p>
)}
{activity.content && (
<p className="text-sm text-gray-700 mb-2 break-words">{activity.content}</p>
)}
{/* Note tipinde dosyaları göster */}
{activity.type === 'note' && activity.data && (activity.data as any).attachedFiles?.length > 0 && (
<div className="mt-2 mb-2">
<p className="text-xs font-medium text-gray-600 mb-1">Ekli Dosyalar:</p>
<div className="space-y-1">
{((activity.data as any).attachedFiles || []).map((file: any, index: number) => (
<div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded border">
<div className="flex items-center gap-2">
<FaFileAlt className="text-blue-500 text-sm" />
<span className="text-xs font-medium">{file.fileName}</span>
<span className="text-xs text-gray-500">({(file.fileSize / 1024).toFixed(1)} KB)</span>
</div>
<Button
variant="plain"
size="xs"
onClick={() => handleDownloadFile(file)}
title="İndir"
className="text-blue-500 hover:text-blue-700"
>
<FaDownload />
</Button>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-gray-500">
<FaClock />
{formatDate(activity.creationTime)}
</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>
)
}

View file

@ -0,0 +1,531 @@
import React, { useState, useEffect } from 'react'
import { Button, Input, Dialog, Select } from '@/components/ui'
import { FaFileUpload, FaEnvelope, FaStickyNote, FaUsers, FaTimes, FaPlus, FaTrash, FaPaperclip } from 'react-icons/fa'
import { FileData } from '@/proxy/formActivity/models'
import { getUsers } from '@/services/identity.service'
import { IdentityUserDto } from '@/proxy/admin/models'
// Birleştirilmiş Not ve Dosya Ekleme Modal'ı
interface AddContentModalProps {
isOpen: boolean
onClose: () => void
onSaveContent: (subject: string, content: string, files: File[]) => Promise<any>
}
export const AddContentModal: React.FC<AddContentModalProps> = ({
isOpen,
onClose,
onSaveContent
}) => {
const [subject, setSubject] = useState('')
const [content, setContent] = useState('')
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const handleFileSelect = (files: FileList | null) => {
if (files && files.length > 0) {
const newFiles = Array.from(files)
setSelectedFiles(prev => [...prev, ...newFiles])
}
}
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index))
}
const handleSave = async () => {
if (content.trim() || selectedFiles.length > 0) {
setUploading(true)
try {
await onSaveContent(subject.trim(), content.trim(), selectedFiles)
resetForm()
onClose()
} catch (error) {
console.error('Save failed:', error)
} finally {
setUploading(false)
}
}
}
const resetForm = () => {
setContent('')
setSelectedFiles([])
setUploading(false)
}
const handleClose = () => {
resetForm()
onClose()
}
const isFormValid = content.trim() || selectedFiles.length > 0
const totalFileSize = selectedFiles.reduce((total, file) => total + file.size, 0)
return (
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose}>
<div className="p-6 w-full mx-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-full">
<FaPlus className="text-purple-600 text-lg" />
</div>
Not Ekle
</h3>
</div>
<div className="space-y-3 mb-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konu <span className="text-red-500">*</span>
</label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajın konusunu girin..."
className="w-full"
size="sm"
/>
</div>
{/* Not Alanı */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<FaStickyNote className="text-yellow-500" />
Not İçeriği <span className="text-red-500">*</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Notunuzu buraya yazın... (İsteğe bağlı)"
className="w-full h-32 p-4 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors duration-200"
style={{ fontSize: '14px', lineHeight: '1.5' }}
/>
<div className="mt-1 text-xs text-gray-500 text-right">
{content.length} karakter
</div>
</div>
{/* Dosya Yükleme Alanı */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<FaPaperclip className="text-blue-500" />
Dosya Ekle
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-purple-400 transition-colors duration-200">
<input
type="file"
multiple
onChange={(e) => handleFileSelect(e.target.files)}
className="hidden"
id="file-upload"
disabled={uploading}
/>
<label
htmlFor="file-upload"
className="cursor-pointer flex flex-col items-center gap-2"
>
<FaFileUpload className="text-2xl text-gray-400" />
<div className="text-sm text-gray-600">
<span className="font-medium text-purple-600 hover:text-purple-700">
Dosya seçmek için tıklayın
</span>
<div className="text-xs text-gray-500 mt-1">
Birden fazla dosya seçebilirsiniz
</div>
</div>
</label>
</div>
{/* Seçili Dosyalar */}
{selectedFiles.length > 0 && (
<div className="mt-4 space-y-2">
<div className="text-sm font-medium text-gray-700">
Seçili Dosyalar ({selectedFiles.length})
</div>
<div className="space-y-2 max-h-32 overflow-y-auto">
{selectedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{file.name}</div>
<div className="text-xs text-gray-500">
{(file.size / 1024).toFixed(2)} KB {file.type || 'Bilinmeyen tür'}
</div>
</div>
<Button
variant="plain"
size="xs"
onClick={() => removeFile(index)}
className="ml-2 text-red-500 hover:text-red-700"
disabled={uploading}
>
<FaTrash />
</Button>
</div>
))}
</div>
<div className="text-xs text-gray-500">
Toplam boyut: {(totalFileSize / 1024).toFixed(2)} KB
</div>
</div>
)}
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<div className="flex gap-3">
<Button
variant="default"
size="md"
onClick={handleClose}
disabled={uploading}
className="px-6"
>
İptal
</Button>
<Button
variant="solid"
size="md"
onClick={handleSave}
disabled={!isFormValid || uploading}
className="px-6 flex items-center gap-2"
>
{uploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Yükleniyor...
</>
) : (
<>
<FaPlus />
Ekle
</>
)}
</Button>
</div>
</div>
</div>
</Dialog>
)
}
// Eski modallar geriye dönük uyumluluk için korundu
interface AddNoteModalProps {
isOpen: boolean
onClose: () => void
onSave: (content: string) => void
}
export const AddNoteModal: React.FC<AddNoteModalProps> = ({ isOpen, onClose, onSave }) => {
return (
<AddContentModal
isOpen={isOpen}
onClose={onClose}
onSaveContent={async (content: string) => {
if (content) {
onSave(content)
}
}}
/>
)
}
interface AddFileModalProps {
isOpen: boolean
onClose: () => void
onUpload: (file: File) => Promise<FileData>
}
export const AddFileModal: React.FC<AddFileModalProps> = ({ isOpen, onClose, onUpload }) => {
return (
<AddContentModal
isOpen={isOpen}
onClose={onClose}
onSaveContent={async (subject: string, content: string, files: File[]) => {
for (const file of files) {
await onUpload(file)
}
}}
/>
)
}
interface UserOption {
value: string
label: string
email?: string
isActive: boolean
}
interface SendMessageModalProps {
isOpen: boolean
onClose: () => void
onSend: (recipients: UserOption[], subject: string, content: string) => void
}
interface MessageTemplate {
value: string
label: string
subject?: string
content?: string
}
const MESSAGE_TEMPLATES: MessageTemplate[] = [
{ value: '', label: 'Şablon seçin...' },
{ value: 'follow_up', label: 'Takip Mesajı', subject: 'Form Durumu', content: 'Merhaba,\n\nForm ile ilgili güncelleme bekliyoruz. Lütfen en kısa sürede dönüş yapabilir misiniz?\n\nTeşekkürler.' },
{ value: 'approval', label: 'Onay Talebi', subject: 'Form İncelemesi', content: 'Sayın yetkili,\n\nForm incelemenizi ve onayınızı rica ediyoruz.\n\nSaygılarımla.' },
{ value: 'rejection', label: 'Red Bildirimi', subject: 'Form Red Edildi', content: 'Merhaba,\n\nForm talebiniz aşağıdaki nedenlerle red edilmiştir:\n\n- [Nedeni buraya yazın]\n\nDüzeltme sonrası tekrar başvurabilirsiniz.' },
{ value: 'completion', label: 'Tamamlanma Bildirimi', subject: 'Form Tamamlandı', content: 'Tebrikler!\n\nForm işleminiz başarıyla tamamlanmıştır.\n\nİyi günler.' }
]
export const SendMessageModal: React.FC<SendMessageModalProps> = ({ isOpen, onClose, onSend }) => {
const [selectedUsers, setSelectedUsers] = useState<UserOption[]>([])
const [subject, setSubject] = useState('')
const [content, setContent] = useState('')
const [users, setUsers] = useState<UserOption[]>([])
const [isLoadingUsers, setIsLoadingUsers] = useState(false)
const [error, setError] = useState<string>('')
const [selectedTemplate, setSelectedTemplate] = useState('')
// Load users when modal opens
useEffect(() => {
if (isOpen) {
loadUsers()
}
}, [isOpen])
const loadUsers = async () => {
setIsLoadingUsers(true)
setError('')
try {
const response = await getUsers(0, 100) // Get first 100 users
const userOptions: UserOption[] = (response.data.items || [])
.filter((user: IdentityUserDto) => user.isActive && user.userName)
.map((user: IdentityUserDto) => ({
value: user.id || '',
label: `${user.name} ${user.surname}` || '',
email: user.email,
isActive: user.isActive
}))
setUsers(userOptions)
} catch (err) {
console.error('Failed to load users:', err)
setError('Kullanıcılar yüklenirken hata oluştu')
} finally {
setIsLoadingUsers(false)
}
}
const handleSend = () => {
if (selectedUsers.length > 0 && subject.trim() && content.trim()) {
onSend(selectedUsers, subject.trim(), content.trim())
resetForm()
onClose()
}
}
const handleTemplateChange = (templateValue: string) => {
setSelectedTemplate(templateValue)
const template = MESSAGE_TEMPLATES.find(t => t.value === templateValue)
if (template && template.value && template.subject && template.content) {
setSubject(template.subject)
setContent(template.content)
}
}
const resetForm = () => {
setSelectedUsers([])
setSubject('')
setContent('')
setError('')
setSelectedTemplate('')
}
const handleClose = () => {
resetForm()
onClose()
}
const isFormValid = selectedUsers.length > 0 && subject.trim() && content.trim()
const customStyles = {
control: (provided: any) => ({
...provided,
minHeight: '42px',
borderRadius: '6px',
borderColor: '#d1d5db',
'&:hover': {
borderColor: '#9ca3af',
},
'&:focus-within': {
borderColor: '#3b82f6',
boxShadow: '0 0 0 2px rgba(59, 130, 246, 0.1)',
},
}),
multiValue: (provided: any) => ({
...provided,
backgroundColor: '#e0e7ff',
borderRadius: '4px',
}),
multiValueLabel: (provided: any) => ({
...provided,
color: '#3730a3',
fontSize: '14px',
}),
multiValueRemove: (provided: any) => ({
...provided,
color: '#6b7280',
'&:hover': {
backgroundColor: '#f87171',
color: 'white',
},
}),
}
return (
<Dialog isOpen={isOpen} onClose={handleClose} onRequestClose={handleClose}>
<div className="p-6 w-full mx-auto">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-semibold flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-full">
<FaEnvelope className="text-green-600 text-lg" />
</div>
Yeni Mesaj Gönder
</h3>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="space-y-5 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mesaj Şablonu
</label>
<Select
value={MESSAGE_TEMPLATES.find(t => t.value === selectedTemplate)}
onChange={(selected) => {
const template = selected as MessageTemplate
handleTemplateChange(template?.value || '')
}}
options={MESSAGE_TEMPLATES}
placeholder="İsteğe bağlı: Hazır şablon seçin..."
isSearchable={false}
isClearable
styles={{
control: (provided: any) => ({
...provided,
minHeight: '38px',
borderRadius: '6px',
borderColor: '#e5e7eb',
}),
}}
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<FaUsers className="text-blue-500" />
Alıcılar <span className="text-red-500">*</span>
</label>
<Select
isMulti
isLoading={isLoadingUsers}
value={selectedUsers}
onChange={(selected) => setSelectedUsers(selected as UserOption[])}
options={users}
placeholder={isLoadingUsers ? "Kullanıcılar yükleniyor..." : "Kullanıcıları seçin..."}
noOptionsMessage={() => "Kullanıcı bulunamadı"}
loadingMessage={() => "Kullanıcılar yükleniyor..."}
isSearchable
styles={customStyles}
closeMenuOnSelect={false}
hideSelectedOptions={false}
formatOptionLabel={(option: UserOption) => (
<div className="flex items-center justify-between py-1">
<div>
<div className="font-medium text-gray-900">{option.label}</div>
</div>
<div className={`w-2 h-2 rounded-full ${option.isActive ? 'bg-green-400' : 'bg-gray-300'}`} />
</div>
)}
/>
{selectedUsers.length > 0 && (
<div className="mt-2 flex items-center justify-between">
<div className="text-sm text-gray-600">
{selectedUsers.length} kullanıcı seçildi
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedUsers([])}
className="text-xs text-gray-500 hover:text-red-500 underline"
>
Tümünü Kaldır
</button>
</div>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Konu <span className="text-red-500">*</span>
</label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Mesajın konusunu girin..."
className="w-full"
size="sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mesaj İçeriği <span className="text-red-500">*</span>
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Mesajınızı buraya yazın..."
className="w-full h-40 p-4 border border-gray-300 rounded-md resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors duration-200"
style={{ fontSize: '14px', lineHeight: '1.5' }}
/>
<div className="mt-1 text-xs text-gray-500 text-right">
{content.length} karakter
</div>
</div>
</div>
<div className="flex justify-between items-center pt-4 border-t border-gray-200">
<div className="flex gap-3">
<Button
variant="default"
size="md"
onClick={handleClose}
className="px-6"
>
İptal
</Button>
<Button
variant="solid"
size="md"
onClick={handleSend}
disabled={!isFormValid}
className="px-6 flex items-center gap-2"
>
<FaEnvelope />
Gönder
</Button>
</div>
</div>
</div>
</Dialog>
)
}

View file

@ -0,0 +1,322 @@
import React, { useState, useRef, useEffect } from 'react'
import { useFormActivity } from './useFormActivity'
import { AddContentModal, SendMessageModal } from './ActivityModals'
import { ActivityList } from './ActivityList'
import { Button, Badge } from '@/components/ui'
import {
FaChevronLeft,
FaChevronRight,
FaPlus,
FaEnvelope,
FaTimes,
FaHistory,
FaGripVertical,
} from 'react-icons/fa'
interface ActivityPanelProps {
entityName: string
entityId: string
isVisible: boolean
onToggle: () => void
}
export const FormActivityPanel: React.FC<ActivityPanelProps> = ({
entityName,
entityId,
isVisible,
onToggle,
}) => {
const [activeTab, setActiveTab] = useState<'activities' | 'notes' | 'messages'>('activities')
const [showAddContent, setShowAddContent] = useState(false)
const [showSendMessage, setShowSendMessage] = useState(false)
// Draggable button state
const [buttonPosition, setButtonPosition] = useState({ top: '50%' })
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ y: 0, startTop: 0 })
const buttonRef = useRef<HTMLDivElement>(null)
const {
notes,
messages,
activities,
addContent,
sendMessage,
deleteNote,
deleteFile,
deleteMessage,
} = useFormActivity(entityName, entityId)
const handleDownloadFile = (fileData: any) => {
// Simulate file download - gerçek implementasyonda API'dan dosya indirme yapılacak
const link = document.createElement('a')
link.href = '#' // gerçek dosya URL'i buraya gelecek
link.download = fileData.fileName
link.click()
}
const getTotalCount = () => notes.length + messages.length
// Mouse drag handlers
const handleMouseDown = (e: React.MouseEvent) => {
if (!buttonRef.current) return
e.preventDefault()
e.stopPropagation()
setIsDragging(true)
const rect = buttonRef.current.getBoundingClientRect()
const startTop = rect.top
setDragStart({
y: e.clientY,
startTop: startTop,
})
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging || !buttonRef.current) return
e.preventDefault()
e.stopPropagation()
const deltaY = e.clientY - dragStart.y
const newTop = dragStart.startTop + deltaY
// Calculate percentage based on viewport
const viewportHeight = window.innerHeight
const buttonHeight = buttonRef.current.offsetHeight
// 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
setButtonPosition({ top: `${topPercentage}%` })
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Add event listeners for mouse move and up
useEffect(() => {
if (isDragging) {
// Disable text selection during drag
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
document.addEventListener('mousemove', handleMouseMove, { passive: false })
document.addEventListener('mouseup', handleMouseUp)
return () => {
// Re-enable text selection
document.body.style.userSelect = ''
document.body.style.webkitUserSelect = ''
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}
}, [isDragging, dragStart])
// Load saved position from localStorage
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
}
return (
<>
{/* Toggle Button - Draggable */}
<div
ref={buttonRef}
className="fixed right-0 z-40"
style={{
top: buttonPosition.top,
transform: 'translateY(-50%)',
}}
>
<div className="group relative">
{/* Drag Handle */}
<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'}`}
onMouseDown={handleMouseDown}
style={{ userSelect: 'none' }}
>
<div className="bg-gray-600 text-white p-1 rounded-l text-xs">
<FaGripVertical />
</div>
</div>
{/* Main Button */}
<Button
variant="solid"
size="sm"
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
className="rounded-l-lg rounded-r-none shadow-lg hover:shadow-xl transition-shadow"
title={isVisible ? 'Aktivite panelini kapat' : 'Aktivite panelini aç'}
>
<div className="flex items-center gap-2">
{isVisible ? <FaChevronRight /> : <FaChevronLeft />}
<FaHistory />
{getTotalCount() > 0 && <Badge content={getTotalCount()} />}
</div>
</Button>
</div>
</div>
{/* Activity Panel */}
<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 ${
isVisible ? 'translate-x-0' : 'translate-x-full'
}`}
style={{ width: '400px' }}
>
<div className="flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-800">Aktiviteler</h3>
<Button variant="plain" size="xs" onClick={onToggle}>
<FaTimes />
</Button>
</div>
<div className="text-sm text-gray-600 mb-3 flex items-center gap-2">
<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>
{/* Action Buttons */}
<div className="flex gap-2 mb-3">
<Button
variant="solid"
size="xs"
onClick={() => setShowAddContent(true)}
className="flex justify-center items-center py-4 w-full"
>
<FaPlus className="mr-1" />
Not Ekle
</Button>
<Button
variant="solid"
size="xs"
onClick={() => setShowSendMessage(true)}
className="flex justify-center items-center py-4 w-full"
>
<FaEnvelope className="mr-1" />
Mesaj Gönder
</Button>
</div>
{/* Tabs */}
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('activities')}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'activities'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Tümü ({getTotalCount()})
</button>
<button
onClick={() => setActiveTab('notes')}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'notes'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Notlar ({notes.length})
</button>
<button
onClick={() => setActiveTab('messages')}
className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'messages'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Mesajlar ({messages.length})
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'activities' && (
<ActivityList
activities={activities}
onDeleteNote={deleteNote}
onDeleteFile={deleteFile}
onDeleteMessage={deleteMessage}
onDownloadFile={handleDownloadFile}
/>
)}
{activeTab === 'notes' && (
<ActivityList
activities={activities.filter((a) => a.type === 'note')}
onDeleteNote={deleteNote}
onDeleteFile={deleteFile}
onDeleteMessage={deleteMessage}
onDownloadFile={handleDownloadFile}
/>
)}
{activeTab === 'messages' && (
<ActivityList
activities={activities.filter((a) => a.type === 'message')}
onDeleteNote={deleteNote}
onDeleteFile={deleteFile}
onDeleteMessage={deleteMessage}
onDownloadFile={handleDownloadFile}
/>
)}
</div>
</div>
</div>
{/* Modals */}
<AddContentModal
isOpen={showAddContent}
onClose={() => setShowAddContent(false)}
onSaveContent={addContent}
/>
<SendMessageModal
isOpen={showSendMessage}
onClose={() => setShowSendMessage(false)}
onSend={sendMessage}
/>
</>
)
}

View file

@ -0,0 +1,218 @@
import { NoteData, FileData, MessageData, ActivityItem } from '@/proxy/formActivity/models'
import { useState, useEffect } from 'react'
const STORAGE_PREFIX = 'form_activity_'
export const useFormActivity = (entityName: string, entityId: string) => {
const [notes, setNotes] = useState<NoteData[]>([])
const [files, setFiles] = useState<FileData[]>([])
const [messages, setMessages] = useState<MessageData[]>([])
const [activities, setActivities] = useState<ActivityItem[]>([])
const storageKey = `${STORAGE_PREFIX}${entityName}_${entityId}`
// Load data from localStorage on component mount
useEffect(() => {
if (!entityName || !entityId) return
const savedData = localStorage.getItem(storageKey)
if (savedData) {
try {
const parsed = JSON.parse(savedData)
setNotes(parsed.notes || [])
setFiles(parsed.files || [])
setMessages(parsed.messages || [])
} catch (error) {
console.error('Failed to load activity data:', error)
}
}
}, [entityName, entityId])
// Update activities when data changes
useEffect(() => {
const allActivities: ActivityItem[] = []
// Add notes as activities (notes can contain files now)
notes.forEach(note => {
allActivities.push({
id: note.id || `note_${Date.now()}`,
type: 'note',
subject: note.subject || '',
content: note.content,
creationTime: note.creationTime || new Date(),
creatorId: note.creatorId || 'Bilinmeyen',
data: note
})
})
// Add messages as activities
messages.forEach(message => {
allActivities.push({
id: message.id || `message_${Date.now()}`,
type: 'message',
subject: message.subject || '',
recipientUserName: message.recipientUserName,
content: message.content,
creationTime: message.creationTime || new Date(),
creatorId: message.creatorId || 'Bilinmeyen',
data: message
})
})
// Sort by timestamp (newest first)
allActivities.sort((a, b) => new Date(b.creationTime).getTime() - new Date(a.creationTime).getTime())
setActivities(allActivities)
}, [notes, files, messages])
// Save to localStorage whenever data changes
useEffect(() => {
if (!entityName || !entityId) return
const dataToSave = {
notes,
files,
messages
}
localStorage.setItem(storageKey, JSON.stringify(dataToSave))
}, [notes, files, messages, storageKey])
const addNote = (subject: string, content: string) => {
const newNote: NoteData = {
id: `note_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
entityName,
entityId,
subject,
content,
creatorId: 'Mevcut Kullanıcı', // Bu gerçek implementasyonda authentication'dan gelecek
creationTime: new Date()
}
setNotes(prev => [...prev, newNote])
return newNote
}
const addContent = async (subject: string, content: string, files: File[]) => {
const timestamp = new Date()
const baseId = `content_${timestamp.getTime()}_${Math.random().toString(36).substr(2, 9)}`
// Hem not hem de dosya varsa veya sadece biri varsa tek bir note aktivitesi oluştur
const newNote: NoteData = {
id: baseId,
entityName,
entityId,
subject,
content: content || (files.length > 0 ? `${files.length} dosya eklendi` : ''),
creatorId: 'Mevcut Kullanıcı',
creationTime: timestamp,
attachedFiles: [] // Dosyaları buraya ekleyeceğiz
}
// Dosyaları yükle ve note'a attach et
const uploadedFiles: FileData[] = []
for (const file of files) {
try {
const uploadedFile = await new Promise<FileData>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const newFile: FileData = {
id: `${baseId}_file_${Math.random().toString(36).substr(2, 9)}`,
entityName,
entityId,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
filePath: `uploads/${entityName}/${entityId}/${file.name}`,
creatorId: 'Mevcut Kullanıcı',
creationTime: timestamp
}
resolve(newFile)
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
uploadedFiles.push(uploadedFile)
} catch (error) {
console.error('File upload failed:', error)
}
}
// Note'a dosyaları ekle
newNote.attachedFiles = uploadedFiles
// Note'u kaydet
setNotes(prev => [...prev, newNote])
// Dosyaları ayrı ayrı da kaydet (eski sistem uyumluluğu için)
if (uploadedFiles.length > 0) {
setFiles(prev => [...prev, ...uploadedFiles])
}
return newNote
}
const addFile = (file: File) => {
return new Promise<FileData>((resolve, reject) => {
// Simulate file upload - gerçek implementasyonda API call yapılacak
const reader = new FileReader()
reader.onload = () => {
const newFile: FileData = {
id: `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
entityName,
entityId,
fileName: file.name,
fileSize: file.size,
fileType: file.type,
filePath: `uploads/${entityName}/${entityId}/${file.name}`, // Simulated path
creatorId: 'Mevcut Kullanıcı',
creationTime: new Date()
}
setFiles(prev => [...prev, newFile])
resolve(newFile)
}
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
}
const sendMessage = (recipients: Array<{ value: string, label: string, email?: string }>, subject: string, content: string) => {
const newMessages: MessageData[] = recipients.map(recipient => ({
id: `message_${Date.now()}_${Math.random().toString(36).substr(2, 9)}_${recipient.value}`,
entityName,
entityId,
recipientUserId: recipient.value,
recipientUserName: recipient.label,
subject,
content,
sentBy: 'Mevcut Kullanıcı',
sentAt: new Date(),
isRead: false
}))
setMessages(prev => [...prev, ...newMessages])
return newMessages
}
const deleteNote = (noteId: string) => {
setNotes(prev => prev.filter(note => note.id !== noteId))
}
const deleteFile = (fileId: string) => {
setFiles(prev => prev.filter(file => file.id !== fileId))
}
const deleteMessage = (messageId: string) => {
setMessages(prev => prev.filter(message => message.id !== messageId))
}
return {
notes,
files,
messages,
activities,
addNote,
addFile,
addContent,
sendMessage,
deleteNote,
deleteFile,
deleteMessage
}
}

View file

@ -9,6 +9,8 @@ import { FormProps } from './types'
import { useGridData } from './useGridData'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Badge } from '@/components/ui'
import { useState } from 'react'
import { FormActivityPanel } from './FormActivityPanel/FormActivityPanel'
const FormEdit = (
props: FormProps = {
@ -23,6 +25,8 @@ const FormEdit = (
const listFormCode = props?.listFormCode ?? params?.listFormCode ?? ''
const id = props?.id ?? params?.id ?? ''
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const [isActivityPanelVisible, setIsActivityPanelVisible] = useState(false)
const { translate } = useLocalization()
const {
@ -60,62 +64,74 @@ const FormEdit = (
}
return (
<Container>
{!isSubForm && (
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle="Sözsoft Kurs Platform"
></Helmet>
)}
<>
<Container className={`${isActivityPanelVisible && !isSubForm ? 'mr-[400px]' : ''} transition-all duration-300`}>
{!isSubForm && (
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle="Sözsoft Kurs Platform"
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={handleSubmit}
refreshData={fetchData}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionView={props?.onActionView}
onActionNew={props?.onActionNew}
/>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
<div className="px-2">
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={setFormData}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={handleSubmit}
refreshData={fetchData}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionView={props?.onActionView}
onActionNew={props?.onActionNew}
/>
)}
</div>
<div className="px-2">
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={setFormData}
listFormCode={listFormCode}
</div>
<SubForms gridDto={gridDto!} formData={formData} level={level ?? 0} />
</Container>
{/* Activity Panel - sadece ana formda göster */}
{!isSubForm && listFormCode && id && gridDto?.gridOptions?.showActivity && (
<FormActivityPanel
entityName={listFormCode}
entityId={id}
isVisible={isActivityPanelVisible}
onToggle={() => setIsActivityPanelVisible(!isActivityPanelVisible)}
/>
</div>
<SubForms gridDto={gridDto!} formData={formData} level={level ?? 0} />
</Container>
)}
</>
)
}

View file

@ -9,6 +9,8 @@ import { FormProps } from './types'
import { useGridData } from './useGridData'
import { useCurrentMenuIcon } from '@/utils/hooks/useCurrentMenuIcon'
import { Badge } from '@/components/ui'
import { useState } from 'react'
import { FormActivityPanel } from './FormActivityPanel/FormActivityPanel'
const FormView = (
props: FormProps = {
@ -24,6 +26,8 @@ const FormView = (
const id = props?.id ?? params?.id ?? ''
const { translate } = useLocalization()
const MenuIcon = useCurrentMenuIcon('w-5 h-5')
const [isActivityPanelVisible, setIsActivityPanelVisible] = useState(false)
const {
loading,
@ -56,61 +60,73 @@ const FormView = (
}
return (
<Container>
{!isSubForm && (
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle="Sözsoft Kurs Platform"
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
<>
<Container className={`${isActivityPanelVisible && !isSubForm ? 'mr-[400px]' : ''} transition-all duration-300`}>
{!isSubForm && (
<Helmet
titleTemplate="%s | Sözsoft Kurs Platform"
title={translate('::' + gridDto?.gridOptions.title)}
defaultTitle="Sözsoft Kurs Platform"
></Helmet>
)}
<div
className={`flex items-center pb-2 px-2 ${isSubForm ? 'justify-end' : 'justify-between'}`}
>
<div className="flex items-center gap-2">
{MenuIcon}
{!isSubForm && (
<>
<h4 className="text-slate-700 text-sm font-medium leading-none">
{translate('::' + gridDto?.gridOptions?.title)}
</h4>
<Badge content={mode} />
</>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
mode={mode}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={() => {}}
refreshData={() => {}}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionEdit={props.onActionEdit}
onActionNew={props.onActionNew}
/>
)}
</div>
{permissionResults && (
<FormButtons
isSubForm={isSubForm}
<div className={`${isSubForm ? 'px-2' : ''}`}>
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={() => {}}
listFormCode={listFormCode}
id={formData?.Id}
gridDto={gridDto!}
commandColumnData={commandColumnData!}
dataSource={dataSource!}
permissions={permissionResults}
handleSubmit={() => {}}
refreshData={() => {}}
getSelectedRowKeys={() => [id]}
getSelectedRowsData={() => [formData]}
getFilter={() => filter}
onActionEdit={props.onActionEdit}
onActionNew={props.onActionNew}
/>
)}
</div>
<div className={`${isSubForm ? 'px-2' : ''}`}>
<FormDevExpress
mode={mode}
refForm={refForm}
formData={formData}
formItems={formItems}
setFormData={() => {}}
listFormCode={listFormCode}
</div>
<SubForms gridDto={gridDto!} formData={formData} level={level ?? 0} refreshData={fetchData} />
</Container>
{/* Activity Panel - sadece ana formda göster */}
{!isSubForm && listFormCode && id && gridDto?.gridOptions?.showActivity && (
<FormActivityPanel
entityName={listFormCode}
entityId={id}
isVisible={isActivityPanelVisible}
onToggle={() => setIsActivityPanelVisible(!isActivityPanelVisible)}
/>
</div>
<SubForms gridDto={gridDto!} formData={formData} level={level ?? 0} refreshData={fetchData} />
</Container>
)}
</>
)
}