This commit is contained in:
2026-06-11 13:25:26 +03:00
parent b931ee64d4
commit 60b48ca5e8
60 changed files with 12302 additions and 0 deletions
@@ -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>
)
}
+92
View File
@@ -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>
)
}
+100
View File
@@ -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>
)
}