feat: admin panel CRUD, Cloudinary upload, logo, user management
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { createCategory, updateCategory } from './actions'
|
||||
import Link from 'next/link'
|
||||
|
||||
type Category = {
|
||||
id: number
|
||||
name_tr: string
|
||||
order_num: number
|
||||
status: number | null
|
||||
}
|
||||
|
||||
export default function CategoryForm({ category }: { category?: Category }) {
|
||||
const isEditing = !!category
|
||||
|
||||
// Use appropriate action based on editing state
|
||||
const actionToUse = isEditing
|
||||
? updateCategory.bind(null, category.id)
|
||||
: createCategory
|
||||
|
||||
const [state, formAction, isPending] = useActionState(actionToUse, undefined)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-6">
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kategori Adı (TR) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_tr"
|
||||
defaultValue={category?.name_tr || ''}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Sıra Numarası
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="order_num"
|
||||
defaultValue={category?.order_num || 0}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="status"
|
||||
id="status"
|
||||
defaultChecked={category ? category.status === 1 : true}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="status" className="text-sm font-medium text-gray-700">
|
||||
Aktif
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Link href="/admin/categories" className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors">
|
||||
İptal
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
|
||||
>
|
||||
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { deleteCategory } from './actions'
|
||||
|
||||
export default function DeleteButton({ id }: { id: number }) {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Bu kategoriyi silmek istediğinize emin misiniz?')) {
|
||||
const res = await deleteCategory(id)
|
||||
if (res?.error) {
|
||||
alert(res.error)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Sil"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import CategoryForm from '../../CategoryForm'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Kategori Düzenle - Admin',
|
||||
}
|
||||
|
||||
export default async function EditCategoryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = await params
|
||||
const id = parseInt(resolvedParams.id, 10)
|
||||
|
||||
if (isNaN(id)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const category = await prisma.categories.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!category) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Kategori Düzenle</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
|
||||
<CategoryForm category={category} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export async function createCategory(_prevState: unknown, formData: FormData) {
|
||||
const name_tr = formData.get('name_tr') as string
|
||||
const name = formData.get('name') as string || name_tr
|
||||
const order_num = parseInt(formData.get('order_num') as string, 10) || 0
|
||||
const status = formData.get('status') === 'on' ? 1 : 0
|
||||
|
||||
if (!name_tr) {
|
||||
return { error: 'Lütfen kategori adını doldurun.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.categories.create({
|
||||
data: {
|
||||
name,
|
||||
name_tr,
|
||||
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
order_num,
|
||||
status,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Create category error:', error)
|
||||
return { error: 'Kategori eklenirken hata oluştu.' }
|
||||
}
|
||||
|
||||
revalidatePath('/admin/categories')
|
||||
revalidatePath('/')
|
||||
redirect('/admin/categories')
|
||||
}
|
||||
|
||||
export async function updateCategory(id: number, _prevState: unknown, formData: FormData) {
|
||||
const name_tr = formData.get('name_tr') as string
|
||||
const name = formData.get('name') as string || name_tr
|
||||
const order_num = parseInt(formData.get('order_num') as string, 10) || 0
|
||||
const status = formData.get('status') === 'on' ? 1 : 0
|
||||
|
||||
if (!name_tr) {
|
||||
return { error: 'Lütfen kategori adını doldurun.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.categories.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
name_tr,
|
||||
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
order_num,
|
||||
status,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update category error:', error)
|
||||
return { error: 'Kategori güncellenirken hata oluştu.' }
|
||||
}
|
||||
|
||||
revalidatePath('/admin/categories')
|
||||
revalidatePath('/')
|
||||
redirect('/admin/categories')
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: number) {
|
||||
try {
|
||||
// Check if category has products
|
||||
const productsCount = await prisma.products.count({
|
||||
where: { category_id: id }
|
||||
})
|
||||
|
||||
if (productsCount > 0) {
|
||||
return { error: 'Bu kategoriye ait ürünler olduğu için silinemez. Önce ürünleri silin veya başka kategoriye taşıyın.' }
|
||||
}
|
||||
|
||||
await prisma.categories.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
revalidatePath('/admin/categories')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Delete category error:', error)
|
||||
return { error: 'Silme işlemi başarısız oldu.' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import CategoryForm from '../CategoryForm'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Yeni Kategori Ekle - Admin',
|
||||
}
|
||||
|
||||
export default function NewCategoryPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Yeni Kategori Ekle</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
|
||||
<CategoryForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { Pencil, Plus } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import DeleteButton from './DeleteButton'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Kategoriler - Admin',
|
||||
}
|
||||
|
||||
export default async function CategoriesPage() {
|
||||
const categories = await prisma.categories.findMany({
|
||||
orderBy: { order_num: 'asc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Kategoriler</h1>
|
||||
<Link href="/admin/categories/new" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors">
|
||||
<Plus size={18} /> Yeni Kategori Ekle
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 border-b border-gray-100 text-sm font-semibold text-gray-600">
|
||||
<th className="py-4 px-6">Kategori Adı</th>
|
||||
<th className="py-4 px-6 text-center">Sıra</th>
|
||||
<th className="py-4 px-6 text-center">Ürün Sayısı</th>
|
||||
<th className="py-4 px-6 text-center">Durum</th>
|
||||
<th className="py-4 px-6 text-right">İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="py-4 px-6 font-medium text-gray-900">{category.name_tr}</td>
|
||||
<td className="py-4 px-6 text-center text-gray-600">{category.order_num}</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
<span className="bg-blue-50 text-blue-700 py-1 px-3 rounded-full text-xs font-medium">
|
||||
{category._count.products} Ürün
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
category.status === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{category.status === 1 ? 'Aktif' : 'Pasif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-6 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/admin/categories/${category.id}/edit`} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Düzenle">
|
||||
<Pencil size={18} />
|
||||
</Link>
|
||||
<DeleteButton id={category.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{categories.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
Henüz kategori bulunmuyor.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link'
|
||||
import { LayoutDashboard, LogOut, Package, Settings, Tags, Users2 } from 'lucide-react'
|
||||
import { logout } from '@/lib/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-white shadow-md flex flex-col hidden md:flex">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-xl font-bold text-gray-800">Admin Panel</h2>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
<Link href="/admin" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<LayoutDashboard size={20} />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<Link href="/admin/categories" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Tags size={20} />
|
||||
<span>Kategoriler</span>
|
||||
</Link>
|
||||
<Link href="/admin/products" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Package size={20} />
|
||||
<span>Ürünler</span>
|
||||
</Link>
|
||||
<Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Settings size={20} />
|
||||
<span>Ayarlar</span>
|
||||
</Link>
|
||||
<Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<Users2 size={20} />
|
||||
<span>Kullanıcılar</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="p-4 border-t">
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await logout()
|
||||
redirect('/admin/login')
|
||||
}}>
|
||||
<button className="flex items-center gap-3 w-full px-3 py-2 text-red-600 rounded-lg hover:bg-red-50 transition-colors">
|
||||
<LogOut size={20} />
|
||||
<span>Çıkış Yap</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 p-6 md:p-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Nav (Optional, simpler for now just a top bar) */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { Package, Tags, Eye, TrendingUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Admin Dashboard - Moy Beach',
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [totalCategories, totalProducts, activeProducts] = await Promise.all([
|
||||
prisma.categories.count(),
|
||||
prisma.products.count(),
|
||||
prisma.products.count({ where: { status: 1 } }),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-100 text-blue-600 rounded-lg">
|
||||
<Tags size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 font-medium">Kategori Sayısı</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalCategories}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-lg">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 font-medium">Toplam Ürün</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalProducts}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 flex items-center gap-4">
|
||||
<div className="p-3 bg-green-100 text-green-600 rounded-lg">
|
||||
<TrendingUp size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 font-medium">Aktif Ürünler</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{activeProducts}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<h2 className="text-lg font-semibold mb-4">Hızlı Kısayollar</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link href="/admin/products" className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2">
|
||||
<Package size={18} /> Ürünleri Yönet
|
||||
</Link>
|
||||
<Link href="/admin/categories" className="text-blue-600 hover:text-blue-800 font-medium flex items-center gap-2">
|
||||
<Tags size={18} /> Kategorileri Yönet
|
||||
</Link>
|
||||
<Link href="/" target="_blank" className="text-gray-600 hover:text-gray-800 font-medium flex items-center gap-2">
|
||||
<Eye size={18} /> Canlı Menüyü Görüntüle
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { deleteProduct } from './actions'
|
||||
|
||||
export default function DeleteButton({ id }: { id: number }) {
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Bu ürünü silmek istediğinize emin misiniz?')) {
|
||||
await deleteProduct(id)
|
||||
}
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Sil"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState, useRef } from 'react'
|
||||
import { createProduct, updateProduct } from './actions'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Upload, X, Loader2 } from 'lucide-react'
|
||||
|
||||
const DEFAULT_IMAGE = '/default-product.png'
|
||||
|
||||
type Category = {
|
||||
id: number
|
||||
name_tr: string
|
||||
}
|
||||
|
||||
type Product = {
|
||||
id: number
|
||||
name_tr: string
|
||||
price: number | string
|
||||
category_id: number | null
|
||||
description_tr: string | null
|
||||
image_url: string | null
|
||||
status: number | null
|
||||
}
|
||||
|
||||
export default function ProductForm({ categories, product }: { categories: Category[], product?: Product }) {
|
||||
const isEditing = !!product
|
||||
const actionToUse = isEditing
|
||||
? updateProduct.bind(null, product.id)
|
||||
: createProduct
|
||||
|
||||
const [state, formAction, isPending] = useActionState(actionToUse, undefined)
|
||||
const [imageUrl, setImageUrl] = useState<string>(product?.image_url || '')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const previewSrc = imageUrl || DEFAULT_IMAGE
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Dosya boyutu 5MB'ı geçemez.")
|
||||
return
|
||||
}
|
||||
|
||||
setUploadError('')
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Yükleme başarısız')
|
||||
setImageUrl(data.url)
|
||||
} catch (err: any) {
|
||||
setUploadError(err.message || 'Resim yüklenirken hata oluştu.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-6">
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Ürün Resmi</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Preview */}
|
||||
<div className="relative w-28 h-28 rounded-xl overflow-hidden border-2 border-gray-200 bg-gray-50 flex-shrink-0">
|
||||
<Image
|
||||
src={previewSrc}
|
||||
alt="Ürün resmi önizleme"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageUrl('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload button */}
|
||||
<div className="flex flex-col gap-2 justify-center h-28">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <><Loader2 size={16} className="animate-spin" /> Yükleniyor...</>
|
||||
: <><Upload size={16} /> Resim Yükle</>
|
||||
}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">PNG, JPG, WEBP — Maks. 5MB</p>
|
||||
{uploadError && <p className="text-xs text-red-600">{uploadError}</p>}
|
||||
{!imageUrl && (
|
||||
<p className="text-xs text-gray-400 italic">Resim yüklenmezse varsayılan görsel kullanılır.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{/* URL'yi form verisiyle gönder */}
|
||||
<input type="hidden" name="image_url" value={imageUrl} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ürün Adı (TR) *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name_tr"
|
||||
defaultValue={product?.name_tr || ''}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategori *</label>
|
||||
<select
|
||||
name="category_id"
|
||||
defaultValue={product?.category_id?.toString() || ''}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
>
|
||||
<option value="" disabled>Seçiniz</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name_tr}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Fiyat (₺) *</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
name="price"
|
||||
defaultValue={product ? product.price.toString() : ''}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama (TR)</label>
|
||||
<textarea
|
||||
name="description_tr"
|
||||
defaultValue={product?.description_tr || ''}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="status"
|
||||
id="status"
|
||||
defaultChecked={product ? product.status === 1 : true}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="status" className="text-sm font-medium text-gray-700">Aktif</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4 border-t">
|
||||
<Link
|
||||
href="/admin/products"
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg font-medium hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
İptal
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
|
||||
>
|
||||
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import ProductForm from '../../ProductForm'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Ürün Düzenle - Admin',
|
||||
}
|
||||
|
||||
export default async function EditProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = await params
|
||||
const id = parseInt(resolvedParams.id, 10)
|
||||
|
||||
if (isNaN(id)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [categories, product] = await Promise.all([
|
||||
prisma.categories.findMany({
|
||||
orderBy: { order_num: 'asc' },
|
||||
select: { id: true, name_tr: true }
|
||||
}),
|
||||
prisma.products.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
])
|
||||
|
||||
if (!product) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ürün Düzenle</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-3xl">
|
||||
<ProductForm
|
||||
categories={categories}
|
||||
product={{
|
||||
...product,
|
||||
price: product.price.toString() // Convert Decimal to string
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export async function createProduct(_prevState: unknown, formData: FormData) {
|
||||
const name_tr = formData.get('name_tr') as string
|
||||
const name = formData.get('name') as string || name_tr
|
||||
const price = parseFloat(formData.get('price') as string)
|
||||
const category_id = parseInt(formData.get('category_id') as string, 10)
|
||||
const description_tr = formData.get('description_tr') as string || ''
|
||||
const image_url = formData.get('image_url') as string || null
|
||||
const status = formData.get('status') === 'on' ? 1 : 0
|
||||
|
||||
if (!name_tr || isNaN(price) || isNaN(category_id)) {
|
||||
return { error: 'Lütfen zorunlu alanları doldurun.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.products.create({
|
||||
data: {
|
||||
name,
|
||||
name_tr,
|
||||
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
price,
|
||||
category_id,
|
||||
description_tr,
|
||||
image_url,
|
||||
status,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Create product error:', error)
|
||||
return { error: 'Ürün eklenirken hata oluştu.' }
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/')
|
||||
redirect('/admin/products')
|
||||
}
|
||||
|
||||
export async function updateProduct(id: number, _prevState: unknown, formData: FormData) {
|
||||
const name_tr = formData.get('name_tr') as string
|
||||
const name = formData.get('name') as string || name_tr
|
||||
const price = parseFloat(formData.get('price') as string)
|
||||
const category_id = parseInt(formData.get('category_id') as string, 10)
|
||||
const description_tr = formData.get('description_tr') as string || ''
|
||||
const image_url = formData.get('image_url') as string || null
|
||||
const status = formData.get('status') === 'on' ? 1 : 0
|
||||
|
||||
if (!name_tr || isNaN(price) || isNaN(category_id)) {
|
||||
return { error: 'Lütfen zorunlu alanları doldurun.' }
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.products.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
name_tr,
|
||||
slug: name_tr.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
price,
|
||||
category_id,
|
||||
description_tr,
|
||||
image_url,
|
||||
status,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update product error:', error)
|
||||
return { error: 'Ürün güncellenirken hata oluştu.' }
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/')
|
||||
redirect('/admin/products')
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: number) {
|
||||
try {
|
||||
await prisma.products.delete({
|
||||
where: { id }
|
||||
})
|
||||
revalidatePath('/admin/products')
|
||||
revalidatePath('/')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Delete product error:', error)
|
||||
return { error: 'Silme işlemi başarısız oldu.' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import ProductForm from '../ProductForm'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Yeni Ürün Ekle - Admin',
|
||||
}
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const categories = await prisma.categories.findMany({
|
||||
orderBy: { order_num: 'asc' },
|
||||
select: { id: true, name_tr: true }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Yeni Ürün Ekle</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-3xl">
|
||||
<ProductForm categories={categories} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { Pencil, Plus, Trash2, Image as ImageIcon } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import DeleteButton from './DeleteButton'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Ürün Yönetimi - Admin',
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const categories = await prisma.categories.findMany({
|
||||
orderBy: { order_num: 'asc' },
|
||||
include: {
|
||||
products: {
|
||||
orderBy: { id: 'asc' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Ürün Yönetimi</h1>
|
||||
<Link href="/admin/products/new" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors">
|
||||
<Plus size={20} />
|
||||
<span>Yeni Ürün Ekle</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-100 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-gray-800">{category.name_tr}</h2>
|
||||
<span className="text-sm text-gray-500 font-medium">{category.products.length} ürün</span>
|
||||
</div>
|
||||
|
||||
{category.products.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="py-3 px-6 text-sm font-semibold text-gray-600 w-16">Resim</th>
|
||||
<th className="py-3 px-6 text-sm font-semibold text-gray-600">İsim</th>
|
||||
<th className="py-3 px-6 text-sm font-semibold text-gray-600">Fiyat</th>
|
||||
<th className="py-3 px-6 text-sm font-semibold text-gray-600">Durum</th>
|
||||
<th className="py-3 px-6 text-sm font-semibold text-gray-600 text-right">İşlemler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-50">
|
||||
{category.products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="py-3 px-6">
|
||||
{product.image_url ? (
|
||||
<div className="relative w-12 h-12 rounded-lg overflow-hidden border border-gray-200">
|
||||
<Image
|
||||
src={product.image_url}
|
||||
alt={product.name_tr}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400 border border-gray-200">
|
||||
<ImageIcon size={20} />
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-6 font-medium text-gray-900">{product.name_tr}</td>
|
||||
<td className="py-3 px-6 text-gray-600">₺{product.price.toString()}</td>
|
||||
<td className="py-3 px-6">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${product.status === 1 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{product.status === 1 ? 'Aktif' : 'Pasif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-6 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={`/admin/products/${product.id}/edit`} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Düzenle">
|
||||
<Pencil size={18} />
|
||||
</Link>
|
||||
<DeleteButton id={product.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 px-6 text-gray-500 text-sm">
|
||||
Bu kategoride henüz ürün yok.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState, useRef } from 'react'
|
||||
import { saveSettings } from './actions'
|
||||
import Image from 'next/image'
|
||||
import { Upload, X, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function SettingsForm({
|
||||
defaultRestaurantName,
|
||||
defaultLocation,
|
||||
defaultLogoUrl,
|
||||
}: {
|
||||
defaultRestaurantName: string
|
||||
defaultLocation: string
|
||||
defaultLogoUrl: string
|
||||
}) {
|
||||
const [state, action, isPending] = useActionState(saveSettings, undefined)
|
||||
const [logoUrl, setLogoUrl] = useState(defaultLogoUrl)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setUploadError("Dosya boyutu 5MB'ı geçemez.")
|
||||
return
|
||||
}
|
||||
|
||||
setUploadError('')
|
||||
setUploading(true)
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await fetch('/api/upload', { method: 'POST', body: fd })
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || 'Yükleme başarısız')
|
||||
setLogoUrl(data.url)
|
||||
} catch (err: any) {
|
||||
setUploadError(err.message || 'Logo yüklenirken hata oluştu.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-6">
|
||||
{state?.success && (
|
||||
<div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm">
|
||||
{state.message}
|
||||
</div>
|
||||
)}
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Site Logosu
|
||||
</label>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Preview */}
|
||||
<div className="relative w-40 h-20 rounded-xl overflow-hidden border-2 border-gray-200 bg-stone-900 flex items-center justify-center flex-shrink-0">
|
||||
{logoUrl ? (
|
||||
<>
|
||||
<Image
|
||||
src={logoUrl}
|
||||
alt="Logo önizleme"
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLogoUrl(''); if (fileInputRef.current) fileInputRef.current.value = '' }}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full p-0.5 hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 text-center px-2">Logo yok<br />(varsayılan kullanılır)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload button */}
|
||||
<div className="flex flex-col gap-2 justify-center h-20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <><Loader2 size={16} className="animate-spin" /> Yükleniyor...</>
|
||||
: <><Upload size={16} /> Logo Yükle</>
|
||||
}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">PNG, SVG, WEBP — Maks. 5MB</p>
|
||||
{uploadError && <p className="text-xs text-red-600">{uploadError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input type="hidden" name="logo_url" value={logoUrl} />
|
||||
</div>
|
||||
|
||||
<hr className="border-gray-100" />
|
||||
|
||||
{/* Restaurant Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Restoran / Mekan Adı
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="restaurant_name"
|
||||
defaultValue={defaultRestaurantName}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Konum Bilgisi
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location"
|
||||
defaultValue={defaultLocation}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || uploading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-70"
|
||||
>
|
||||
{isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function saveSettings(_prevState: unknown, formData: FormData) {
|
||||
const restaurant_name = formData.get('restaurant_name') as string
|
||||
const location = formData.get('location') as string
|
||||
const logo_url = formData.get('logo_url') as string
|
||||
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'restaurant_name' },
|
||||
update: { value: restaurant_name },
|
||||
create: { key: 'restaurant_name', value: restaurant_name },
|
||||
}),
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'location' },
|
||||
update: { value: location },
|
||||
create: { key: 'location', value: location },
|
||||
}),
|
||||
prisma.settings.upsert({
|
||||
where: { key: 'logo_url' },
|
||||
update: { value: logo_url || '' },
|
||||
create: { key: 'logo_url', value: logo_url || '' },
|
||||
}),
|
||||
])
|
||||
|
||||
revalidatePath('/')
|
||||
revalidatePath('/admin/settings')
|
||||
|
||||
return { success: true, message: 'Ayarlar başarıyla kaydedildi.' }
|
||||
} catch (error) {
|
||||
console.error('Settings save error:', error)
|
||||
return { error: 'Ayarlar kaydedilirken bir hata oluştu.' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import SettingsForm from './SettingsForm'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Genel Ayarlar - Admin',
|
||||
}
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const settingsData = await prisma.settings.findMany()
|
||||
const settings = settingsData.reduce((acc, curr) => {
|
||||
acc[curr.key] = curr.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
const restaurantName = settings['restaurant_name'] || 'Moy Beach'
|
||||
const location = settings['location'] || 'Akyaka · Muğla'
|
||||
const logoUrl = settings['logo_url'] || ''
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Genel Ayarlar</h1>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 max-w-2xl">
|
||||
<SettingsForm
|
||||
defaultRestaurantName={restaurantName}
|
||||
defaultLocation={location}
|
||||
defaultLogoUrl={logoUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState } from 'react'
|
||||
import { createUser } from './actions'
|
||||
import { UserPlus } from 'lucide-react'
|
||||
|
||||
export default function CreateUserForm() {
|
||||
const [state, formAction, isPending] = useActionState(createUser, undefined)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<UserPlus size={20} className="text-blue-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Yeni Kullanıcı Ekle</h2>
|
||||
</div>
|
||||
|
||||
{state?.success && (
|
||||
<div className="bg-green-50 text-green-700 p-3 rounded-lg text-sm mb-4">
|
||||
Kullanıcı başarıyla oluşturuldu.
|
||||
</div>
|
||||
)}
|
||||
{state?.error && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-lg text-sm mb-4">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={formAction} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kullanıcı Adı *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Şifre * <span className="text-gray-400 font-normal">(min. 6 karakter)</span></label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rol</label>
|
||||
<select
|
||||
name="role"
|
||||
defaultValue="admin"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 outline-none text-sm"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-5 rounded-lg text-sm transition-colors disabled:opacity-70 flex items-center gap-2"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{isPending ? 'Oluşturuluyor...' : 'Kullanıcı Oluştur'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { changePassword, deleteUser } from './actions'
|
||||
import { KeyRound, Trash2, ChevronDown, ChevronUp, Shield } from 'lucide-react'
|
||||
|
||||
type User = {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
function ChangePasswordForm({ userId }: { userId: number }) {
|
||||
const action = changePassword.bind(null, userId)
|
||||
const [state, formAction, isPending] = useActionState(action, undefined)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
|
||||
{state?.success && (
|
||||
<p className="text-xs text-green-700 bg-green-50 px-3 py-2 rounded-lg">{state.message}</p>
|
||||
)}
|
||||
{state?.error && (
|
||||
<p className="text-xs text-red-700 bg-red-50 px-3 py-2 rounded-lg">{state.error}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Yeni Şifre</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new_password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="Min. 6 karakter"
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Şifre Tekrar</label>
|
||||
<input
|
||||
type="password"
|
||||
name="confirm_password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
placeholder="Tekrar giriniz"
|
||||
className="w-full px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white text-sm font-medium py-1.5 px-4 rounded-lg transition-colors disabled:opacity-70 flex items-center gap-1.5"
|
||||
>
|
||||
<KeyRound size={14} />
|
||||
{isPending ? 'Güncelleniyor...' : 'Şifreyi Güncelle'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function UserRow({ user }: { user: User }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`"${user.username}" kullanıcısını silmek istediğinize emin misiniz?`)) return
|
||||
const res = await deleteUser(user.id)
|
||||
if (res?.error) alert(res.error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 font-semibold text-sm uppercase">
|
||||
{user.username[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">{user.username}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(user.created_at).toLocaleDateString('tr-TR', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<Shield size={10} />
|
||||
{user.role}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-600 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<KeyRound size={13} />
|
||||
Şifre
|
||||
{expanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Kullanıcıyı Sil"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && <ChangePasswordForm userId={user.id} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserList({ users }: { users: User[] }) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">Mevcut Kullanıcılar ({users.length})</h2>
|
||||
{users.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-6">Henüz kullanıcı yok.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users.map(user => (
|
||||
<UserRow key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use server'
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
|
||||
export async function createUser(_prevState: unknown, formData: FormData) {
|
||||
const username = (formData.get('username') as string)?.trim()
|
||||
const password = formData.get('password') as string
|
||||
const role = (formData.get('role') as string) || 'admin'
|
||||
|
||||
if (!username || !password) {
|
||||
return { error: 'Kullanıcı adı ve şifre zorunludur.' }
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return { error: 'Şifre en az 6 karakter olmalıdır.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.users.findUnique({ where: { username } })
|
||||
if (existing) {
|
||||
return { error: 'Bu kullanıcı adı zaten kullanılıyor.' }
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 12)
|
||||
await prisma.users.create({ data: { username, password_hash, role } })
|
||||
revalidatePath('/admin/users')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Create user error:', error)
|
||||
return { error: 'Kullanıcı oluşturulurken hata oluştu.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function changePassword(userId: number, _prevState: unknown, formData: FormData) {
|
||||
const newPassword = formData.get('new_password') as string
|
||||
const confirmPassword = formData.get('confirm_password') as string
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return { error: 'Şifre en az 6 karakter olmalıdır.' }
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return { error: 'Şifreler eşleşmiyor.' }
|
||||
}
|
||||
|
||||
try {
|
||||
const password_hash = await bcrypt.hash(newPassword, 12)
|
||||
await prisma.users.update({ where: { id: userId }, data: { password_hash } })
|
||||
revalidatePath('/admin/users')
|
||||
return { success: true, message: 'Şifre güncellendi.' }
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error)
|
||||
return { error: 'Şifre güncellenirken hata oluştu.' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: number) {
|
||||
try {
|
||||
// Count total users — don't allow deleting the last admin
|
||||
const count = await prisma.users.count()
|
||||
if (count <= 1) {
|
||||
return { error: 'Son yönetici kullanıcı silinemez.' }
|
||||
}
|
||||
await prisma.users.delete({ where: { id: userId } })
|
||||
revalidatePath('/admin/users')
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error)
|
||||
return { error: 'Kullanıcı silinirken hata oluştu.' }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import CreateUserForm from './CreateUserForm'
|
||||
import UserList from './UserList'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Kullanıcı Yönetimi - Admin',
|
||||
}
|
||||
|
||||
export default async function UsersPage() {
|
||||
const users = await prisma.users.findMany({
|
||||
orderBy: { created_at: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
role: true,
|
||||
created_at: true,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Kullanıcı Yönetimi</h1>
|
||||
<CreateUserForm />
|
||||
<UserList users={users} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user