feat: implement intern blog management system with approval workflows and administrative feedback
This commit is contained in:
@@ -2,11 +2,14 @@
|
||||
|
||||
namespace App\Filament\Admin\Resources\Blogs\Tables;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ForceDeleteBulkAction;
|
||||
use Filament\Actions\RestoreBulkAction;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\ToggleColumn;
|
||||
@@ -32,26 +35,45 @@ class BlogsTable
|
||||
->sortable()
|
||||
->limit(50),
|
||||
|
||||
TextColumn::make('slug')
|
||||
->label(__('blog.table_slug'))
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('status')
|
||||
->label(__('blog.table_status'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
->color(fn (?string $state): string => match ($state) {
|
||||
'draft' => 'gray',
|
||||
'pending' => 'info',
|
||||
'published' => 'success',
|
||||
'rejected' => 'danger',
|
||||
'archived' => 'warning',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'draft' => __('blog.status_draft'),
|
||||
'published' => __('blog.status_published'),
|
||||
'archived' => __('blog.status_archived'),
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'draft' => 'Taslak',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'published' => 'Yayınlandı',
|
||||
'rejected' => 'Revize İstendi',
|
||||
'archived' => 'Arşivlendi',
|
||||
default => $state ?? '-',
|
||||
}),
|
||||
|
||||
TextColumn::make('careerApplication.name')
|
||||
->label('Stajyer')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('purple')
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('intern_category')
|
||||
->label('Staj Konusu')
|
||||
->formatStateUsing(fn (?string $state): string => match ($state) {
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
default => $state ?? '-',
|
||||
})
|
||||
->badge()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('author.name')
|
||||
->label(__('blog.table_author'))
|
||||
->searchable()
|
||||
@@ -75,39 +97,79 @@ class BlogsTable
|
||||
ToggleColumn::make('is_featured')
|
||||
->label(__('blog.is_featured_field'))
|
||||
->alignCenter(),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->label(__('blog.table_created_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label(__('blog.table_updated_at'))
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('status')
|
||||
->label(__('blog.status_field'))
|
||||
->options([
|
||||
'draft' => __('blog.status_draft'),
|
||||
'published' => __('blog.status_published'),
|
||||
'archived' => __('blog.status_archived'),
|
||||
'draft' => 'Taslak',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'published' => 'Yayınlandı',
|
||||
'rejected' => 'Revize İstendi',
|
||||
'archived' => 'Arşivlendi',
|
||||
]),
|
||||
|
||||
SelectFilter::make('intern_category')
|
||||
->label('Staj Blog Konusu')
|
||||
->options([
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
]),
|
||||
|
||||
TernaryFilter::make('is_intern_blog')
|
||||
->label('Sadece Stajyer Yazıları')
|
||||
->queries(
|
||||
true: fn ($query) => $query->whereNotNull('career_application_id'),
|
||||
false: fn ($query) => $query->whereNull('career_application_id'),
|
||||
),
|
||||
|
||||
SelectFilter::make('category_id')
|
||||
->label(__('blog.category_field'))
|
||||
->relationship('category', 'name'),
|
||||
|
||||
TernaryFilter::make('is_featured')
|
||||
->label(__('blog.is_featured_field')),
|
||||
|
||||
TernaryFilter::make('allow_comments')
|
||||
->label(__('blog.allow_comments_field')),
|
||||
])
|
||||
->recordActions([
|
||||
Action::make('approve')
|
||||
->label('Onayla & Yayınla')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->visible(fn ($record) => in_array($record->status, ['pending', 'draft', 'rejected']))
|
||||
->action(function ($record) {
|
||||
$record->status = 'published';
|
||||
$record->published_at = now();
|
||||
$record->admin_feedback = null;
|
||||
$record->save();
|
||||
|
||||
Notification::make()
|
||||
->title('Yazı Onaylandı')
|
||||
->body('Blog yazısı başarıyla yayınlandı.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('reject')
|
||||
->label('Revize İstə')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->visible(fn ($record) => in_array($record->status, ['pending', 'published']))
|
||||
->form([
|
||||
Textarea::make('admin_feedback')
|
||||
->label('Revizyon Gerekçesi / Stajyere Not')
|
||||
->required()
|
||||
->placeholder('Örn: Başlığı ve içerikteki kod bloklarını düzenleyiniz.'),
|
||||
])
|
||||
->action(function ($record, array $data) {
|
||||
$record->status = 'rejected';
|
||||
$record->admin_feedback = $data['admin_feedback'];
|
||||
$record->save();
|
||||
|
||||
Notification::make()
|
||||
->title('Revizyon Talebi Gönderildi')
|
||||
->body('Stajyere revizyon bildirimi iletildi.')
|
||||
->warning()
|
||||
->send();
|
||||
}),
|
||||
|
||||
EditAction::make()
|
||||
->label(__('blog.edit')),
|
||||
])
|
||||
|
||||
@@ -363,7 +363,71 @@ class InternApplicationResource extends Resource
|
||||
"#### 📝 Danışman Görüşü ve Değerlendirme Notu\n" .
|
||||
"\"Stajyerimiz, staj süresi boyunca kendisine verilen görevleri büyük bir titizlikle yerine getirmiştir. Özellikle karşılaştığı teknik problemlere getirdiği pratik çözümler ve yeni teknolojileri öğrenme isteği takdir edilmeye değerdir. Kurumumuz bünyesinde yürüttüğümüz projelere sağladığı katkılardan ötürü teşekkür eder, profesyonel kariyerinde başarılar dileriz.\"";
|
||||
}),
|
||||
])->columns(1)
|
||||
])->columns(1),
|
||||
|
||||
Tab::make('Stajyer Blog Yazıları')
|
||||
->icon('heroicon-o-newspaper')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Placeholder::make('intern_blogs_view')
|
||||
->label('Yazılan Blog Yazıları')
|
||||
->content(function ($record) {
|
||||
if (!$record) return 'Henüz kayıt bulunmuyor.';
|
||||
$blogs = $record->blogs()->orderBy('created_at', 'desc')->get();
|
||||
if ($blogs->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString('<div style="padding: 15px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; color: #64748b; font-size: 13px;">Bu stajyer henüz blog yazısı oluşturmadı.</div>');
|
||||
}
|
||||
|
||||
$html = '<div style="space-y: 12px;">';
|
||||
foreach ($blogs as $b) {
|
||||
$catLabel = match ($b->intern_category) {
|
||||
'experience' => '1. Staj Tecrübesi',
|
||||
'technical_challenge' => '2. Teknik Zorluklar',
|
||||
'product_showcase' => '3. Ürün Tanıtımı',
|
||||
default => $b->intern_category ?? '-',
|
||||
};
|
||||
$statusBg = match ($b->status) {
|
||||
'published' => '#d1fae5',
|
||||
'pending' => '#fef3c7',
|
||||
'rejected' => '#fee2e2',
|
||||
default => '#f1f5f9',
|
||||
};
|
||||
$statusColor = match ($b->status) {
|
||||
'published' => '#065f46',
|
||||
'pending' => '#92400e',
|
||||
'rejected' => '#991b1b',
|
||||
default => '#475569',
|
||||
};
|
||||
$statusText = match ($b->status) {
|
||||
'published' => 'Yayınlandı',
|
||||
'pending' => 'Onay Bekliyor',
|
||||
'rejected' => 'Revize İstendi',
|
||||
default => 'Taslak',
|
||||
};
|
||||
|
||||
$html .= '<div style="padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; background: #ffffff; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">';
|
||||
$html .= ' <div>';
|
||||
$html .= ' <div style="display: flex; gap: 8px; align-items: center; margin-bottom: 4px;">';
|
||||
$html .= ' <span style="font-size: 11px; font-weight: 800; background: #eff6ff; color: #1d4ed8; padding: 2px 8px; border-radius: 6px;">' . e($catLabel) . '</span>';
|
||||
$html .= ' <span style="font-size: 11px; font-weight: 800; background: ' . $statusBg . '; color: ' . $statusColor . '; padding: 2px 8px; border-radius: 6px;">' . $statusText . '</span>';
|
||||
$html .= ' </div>';
|
||||
$html .= ' <h4 style="margin: 0; font-size: 14px; font-weight: bold; color: #1e293b;">' . e($b->title) . '</h4>';
|
||||
if ($b->admin_feedback) {
|
||||
$html .= ' <p style="margin: 4px 0 0 0; font-size: 11px; color: #dc2626;"><strong>Revizyon Notu:</strong> ' . e($b->admin_feedback) . '</p>';
|
||||
}
|
||||
$html .= ' </div>';
|
||||
$html .= ' <div>';
|
||||
if ($b->status === 'published') {
|
||||
$html .= ' <a href="/blog/' . $b->slug . '" target="_blank" style="padding: 6px 12px; background: #059669; color: white; border-radius: 6px; font-size: 11px; font-weight: bold; text-decoration: none;">Sitede Gör</a>';
|
||||
}
|
||||
$html .= ' </div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
])->columnSpanFull()
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CareerApplication;
|
||||
use App\Models\Blog;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CareerController extends Controller
|
||||
{
|
||||
@@ -98,8 +100,11 @@ class CareerController extends Controller
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$intern = CareerApplication::where('username', $request->username)
|
||||
->where('type', 'internship')
|
||||
$intern = CareerApplication::where('type', 'internship')
|
||||
->where(function ($q) use ($request) {
|
||||
$q->where('username', $request->username)
|
||||
->orWhere('email', $request->username);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$intern || !\Illuminate\Support\Facades\Hash::check($request->password, $intern->password)) {
|
||||
@@ -124,18 +129,109 @@ class CareerController extends Controller
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
$days = self::getInternshipDates($intern->internship_start_date, $intern->internship_total_days);
|
||||
$savedEntries = $intern->journalEntries()->get()->keyBy('day_number');
|
||||
$blogs = $intern->blogs()->orderBy('created_at', 'desc')->get();
|
||||
|
||||
return view('front.career.intern_dashboard', [
|
||||
'intern' => $intern,
|
||||
'days' => $days,
|
||||
'savedEntries' => $savedEntries,
|
||||
'blogs' => $blogs,
|
||||
'meta' => [
|
||||
'title' => 'Stajyer Paneli',
|
||||
'description' => 'Belgelerinizi buradan indirebilirsiniz.',
|
||||
'description' => 'Staj belgelerinizi ve blog yazılarınızı yönetin.',
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveInternBlog(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen önce giriş yapın.');
|
||||
}
|
||||
|
||||
$intern = CareerApplication::findOrFail(session('intern_id'));
|
||||
|
||||
$request->validate([
|
||||
'blog_id' => 'nullable|integer|exists:blogs,id',
|
||||
'title' => 'required|string|max:255',
|
||||
'intern_category' => 'required|string|in:experience,technical_challenge,product_showcase',
|
||||
'excerpt' => 'nullable|string|max:500',
|
||||
'content' => 'required|string|min:50',
|
||||
'featured_image' => 'nullable|image|mimes:jpeg,png,jpg,webp|max:5120',
|
||||
'action_type' => 'required|string|in:draft,submit',
|
||||
], [
|
||||
'title.required' => 'Lütfen blog yazısı başlığını girin.',
|
||||
'intern_category.required' => 'Lütfen bir kategori seçin.',
|
||||
'content.required' => 'Lütfen blog yazısı içeriğini girin.',
|
||||
'content.min' => 'Blog içeriği en az 50 karakter olmalıdır.',
|
||||
'featured_image.image' => 'Görsel geçerli bir resim dosyası olmalıdır.',
|
||||
]);
|
||||
|
||||
$status = $request->action_type === 'draft' ? 'draft' : 'pending';
|
||||
|
||||
if ($request->filled('blog_id')) {
|
||||
$blog = Blog::where('id', $request->blog_id)
|
||||
->where('career_application_id', $intern->id)
|
||||
->firstOrFail();
|
||||
} else {
|
||||
$blog = new Blog();
|
||||
$blog->career_application_id = $intern->id;
|
||||
}
|
||||
|
||||
// Handle slug
|
||||
if (!$blog->exists || $blog->title !== $request->title) {
|
||||
$baseSlug = Str::slug($request->title);
|
||||
$slug = $baseSlug;
|
||||
$count = 1;
|
||||
while (Blog::where('slug', $slug)->where('id', '!=', $blog->id ?? 0)->exists()) {
|
||||
$slug = $baseSlug . '-' . $count;
|
||||
$count++;
|
||||
}
|
||||
$blog->slug = $slug;
|
||||
}
|
||||
|
||||
$blog->title = $request->title;
|
||||
$blog->intern_category = $request->intern_category;
|
||||
$blog->excerpt = $request->excerpt;
|
||||
$blog->content = $request->content;
|
||||
$blog->status = $status;
|
||||
$blog->meta_title = $request->title;
|
||||
$blog->meta_description = Str::limit(strip_tags($request->excerpt ?: $request->content), 160);
|
||||
|
||||
if ($request->hasFile('featured_image')) {
|
||||
if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) {
|
||||
Storage::disk('public')->delete($blog->featured_image);
|
||||
}
|
||||
$blog->featured_image = $request->file('featured_image')->store('blogs', 'public');
|
||||
}
|
||||
|
||||
$blog->save();
|
||||
|
||||
$msg = $status === 'draft' ? 'Blog yazısı taslak olarak kaydedildi.' : 'Blog yazısı incelemeye gönderildi.';
|
||||
return redirect()->back()->with('success', $msg);
|
||||
}
|
||||
|
||||
public function deleteInternBlog($id)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
return redirect()->route('intern.login')->with('error', 'Lütfen giriş yapın.');
|
||||
}
|
||||
|
||||
$blog = Blog::where('id', $id)
|
||||
->where('career_application_id', session('intern_id'))
|
||||
->firstOrFail();
|
||||
|
||||
if (in_array($blog->status, ['draft', 'pending', 'rejected'])) {
|
||||
if ($blog->featured_image && Storage::disk('public')->exists($blog->featured_image)) {
|
||||
Storage::disk('public')->delete($blog->featured_image);
|
||||
}
|
||||
$blog->forceDelete();
|
||||
return redirect()->back()->with('success', 'Blog yazısı silindi.');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', 'Yayınlanmış blog yazıları silinemez.');
|
||||
}
|
||||
|
||||
public function uploadInternshipForm(Request $request)
|
||||
{
|
||||
if (!session()->has('intern_id')) {
|
||||
|
||||
@@ -23,6 +23,9 @@ class Blog extends Model
|
||||
'published_at',
|
||||
'author_id',
|
||||
'category_id',
|
||||
'career_application_id',
|
||||
'intern_category',
|
||||
'admin_feedback',
|
||||
'tags',
|
||||
'view_count',
|
||||
'is_featured',
|
||||
@@ -53,6 +56,11 @@ class Blog extends Model
|
||||
return $this->belongsTo(User::class, 'author_id');
|
||||
}
|
||||
|
||||
public function careerApplication()
|
||||
{
|
||||
return $this->belongsTo(CareerApplication::class, 'career_application_id');
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(BlogCategory::class, 'category_id');
|
||||
|
||||
@@ -48,6 +48,14 @@ class CareerApplication extends Model
|
||||
return $this->hasMany(InternshipJournalEntry::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blog posts written by this intern.
|
||||
*/
|
||||
public function blogs()
|
||||
{
|
||||
return $this->hasMany(Blog::class, 'career_application_id');
|
||||
}
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($model) {
|
||||
|
||||
Reference in New Issue
Block a user