sms entegrasyonu ve ana sayfa işlemleri
This commit is contained in:
76
app/(dashboard)/dashboard/customers/page.tsx
Normal file
76
app/(dashboard)/dashboard/customers/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getCustomers, deleteCustomer } from "@/lib/customers/actions"
|
||||
import { CustomerForm } from "@/components/dashboard/customer-form"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Trash, Edit } from "lucide-react"
|
||||
|
||||
export default async function CustomersPage() {
|
||||
const { data: customers } = await getCustomers()
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
<h2 className="text-3xl font-bold tracking-tight">Müşteriler</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CustomerForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Ad Soyad</TableHead>
|
||||
<TableHead>E-Posta</TableHead>
|
||||
<TableHead>Telefon</TableHead>
|
||||
<TableHead>Adres</TableHead>
|
||||
<TableHead className="w-[100px]">İşlemler</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customers?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
Henüz müşteri bulunmuyor.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{customers?.map((customer) => (
|
||||
<TableRow key={customer.id}>
|
||||
<TableCell className="font-medium">{customer.full_name}</TableCell>
|
||||
<TableCell>{customer.email || "-"}</TableCell>
|
||||
<TableCell>{customer.phone || "-"}</TableCell>
|
||||
<TableCell className="truncate max-w-[200px]">{customer.address || "-"}</TableCell>
|
||||
<TableCell className="flex items-center gap-2">
|
||||
<CustomerForm
|
||||
customer={customer}
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<form action={async () => {
|
||||
'use server'
|
||||
await deleteCustomer(customer.id)
|
||||
}}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive">
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,15 @@ export default async function SettingsPage() {
|
||||
{ key: 'site_title', value: 'ParaKasa', type: 'text', section: 'general' },
|
||||
{ key: 'site_description', value: '', type: 'long_text', section: 'general' },
|
||||
{ key: 'site_logo', value: '', type: 'image_url', section: 'general' },
|
||||
{ key: 'favicon_url', value: '', type: 'image_url', section: 'general' },
|
||||
|
||||
// Home
|
||||
{ key: 'home_hero_title', value: 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>', type: 'html', section: 'home' },
|
||||
{ key: 'home_hero_description', value: 'En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu.', type: 'long_text', section: 'home' },
|
||||
{ key: 'home_hero_button_text', value: 'Koleksiyonu İncele', type: 'text', section: 'home' },
|
||||
{ key: 'home_hero_bg_image', value: '/images/hero-safe.png', type: 'image_url', section: 'home' },
|
||||
{ key: 'home_categories_title', value: 'Ürün Kategorileri', type: 'text', section: 'home' },
|
||||
{ key: 'home_categories_description', value: 'İhtiyacınıza uygun güvenlik çözümünü seçin.', type: 'text', section: 'home' },
|
||||
|
||||
// Contact
|
||||
{ key: 'contact_phone', value: '', type: 'text', section: 'contact' },
|
||||
@@ -37,6 +46,14 @@ export default async function SettingsPage() {
|
||||
{ key: 'social_youtube', value: '', type: 'text', section: 'contact' },
|
||||
{ key: 'social_tiktok', value: '', type: 'text', section: 'contact' },
|
||||
{ key: 'contact_map_embed', value: '', type: 'html', section: 'contact' },
|
||||
|
||||
// SEO
|
||||
{ key: 'meta_keywords', value: '', type: 'text', section: 'seo' },
|
||||
{ key: 'meta_author', value: '', type: 'text', section: 'seo' },
|
||||
|
||||
// Scripts & Analytics
|
||||
{ key: 'google_analytics_id', value: '', type: 'text', section: 'scripts' },
|
||||
{ key: 'facebook_pixel_id', value: '', type: 'text', section: 'scripts' },
|
||||
]
|
||||
|
||||
// Merge default contents with existing contents
|
||||
|
||||
65
app/(dashboard)/dashboard/sms/logs/page.tsx
Normal file
65
app/(dashboard)/dashboard/sms/logs/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getSmsLogs } from "@/lib/sms/actions"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { format } from "date-fns"
|
||||
import { tr } from "date-fns/locale"
|
||||
|
||||
export default async function SmsLogsPage() {
|
||||
const { data: logs, error } = await getSmsLogs(100)
|
||||
|
||||
if (error) {
|
||||
return <div className="p-8">Hata: {error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">SMS Geçmişi</h2>
|
||||
<p className="text-muted-foreground">Son gönderilen mesajların durumu.</p>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">Tarih</TableHead>
|
||||
<TableHead>Numara</TableHead>
|
||||
<TableHead>Mesaj</TableHead>
|
||||
<TableHead>Durum</TableHead>
|
||||
<TableHead className="text-right">Kod</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
Kayıt bulunamadı.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{logs?.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-medium">
|
||||
{log.created_at ? format(new Date(log.created_at), "dd MMM yyyy HH:mm", { locale: tr }) : "-"}
|
||||
</TableCell>
|
||||
<TableCell>{log.phone}</TableCell>
|
||||
<TableCell className="max-w-[300px] truncate" title={log.message}>{log.message}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={log.status === 'success' ? 'default' : 'destructive'}>
|
||||
{log.status === 'success' ? 'Başarılı' : 'Hatalı'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">{log.response_code}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
app/(dashboard)/dashboard/sms/page.tsx
Normal file
9
app/(dashboard)/dashboard/sms/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { getCustomers } from "@/lib/customers/actions"
|
||||
import SmsPageClient from "@/components/dashboard/sms-page-client"
|
||||
|
||||
export default async function SmsPage() {
|
||||
// Fetch all customers to show in the list
|
||||
const { data: customers } = await getCustomers()
|
||||
|
||||
return <SmsPageClient customers={customers || []} />
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import { ArrowRight, ShieldCheck, Lock, History, LayoutDashboard } from "lucide-
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { createClient } from "@/lib/supabase-server"
|
||||
import { getSiteContents } from "@/lib/data"
|
||||
|
||||
export default async function Home() {
|
||||
const supabase = createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
const contents = await getSiteContents()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
@@ -15,7 +17,7 @@ export default async function Home() {
|
||||
<section className="relative w-full h-[80vh] flex items-center bg-black overflow-hidden">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Image
|
||||
src="/images/hero-safe.png"
|
||||
src={contents.home_hero_bg_image || "/images/hero-safe.png"}
|
||||
alt="Premium Çelik Kasa"
|
||||
fill
|
||||
className="object-cover opacity-60"
|
||||
@@ -26,16 +28,16 @@ export default async function Home() {
|
||||
|
||||
<div className="container relative z-10 px-4 md:px-6">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit">
|
||||
GÜVENLİK SINIR <br /> <span className="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>
|
||||
</h1>
|
||||
<h1
|
||||
className="text-3xl md:text-6xl lg:text-7xl font-bold tracking-tighter text-white font-outfit"
|
||||
dangerouslySetInnerHTML={{ __html: contents.home_hero_title || 'GÜVENLİK SINIR <br /> <span class="text-transparent bg-clip-text bg-gradient-to-r from-slate-200 to-slate-500">TANIMAZ</span>' }}
|
||||
/>
|
||||
<p className="text-lg md:text-xl text-slate-300 max-w-[600px]">
|
||||
En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar.
|
||||
Modern teknoloji ve zanaatkarlığın mükemmel uyumu.
|
||||
{contents.home_hero_description || "En değerli varlıklarınız için tasarlanmış yüksek güvenlikli çelik kasalar. Modern teknoloji ve zanaatkarlığın mükemmel uyumu."}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 mt-8">
|
||||
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8">
|
||||
<Link href="/products">Koleksiyonu İncele</Link>
|
||||
<Button size="lg" className="bg-slate-100 text-slate-900 hover:bg-slate-200 font-semibold text-lg px-8" asChild>
|
||||
<Link href="/products">{contents.home_hero_button_text || "Koleksiyonu İncele"}</Link>
|
||||
</Button>
|
||||
{user && (
|
||||
<Button size="lg" variant="outline" className="border-slate-400 text-slate-100 hover:bg-slate-800 hover:text-white font-semibold text-lg px-8">
|
||||
@@ -54,9 +56,9 @@ export default async function Home() {
|
||||
<section className="py-20 md:py-32 bg-slate-50 dark:bg-slate-950">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center mb-16">
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">Ürün Kategorileri</h2>
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tighter font-outfit">{contents.home_categories_title || "Ürün Kategorileri"}</h2>
|
||||
<p className="max-w-[700px] text-muted-foreground md:text-lg">
|
||||
İhtiyacınıza uygun güvenlik çözümünü seçin.
|
||||
{contents.home_categories_description || "İhtiyacınıza uygun güvenlik çözümünü seçin."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ const SECTIONS = [
|
||||
{ id: 'general', label: 'Genel Ayarlar' },
|
||||
{ id: 'home', label: 'Anasayfa' },
|
||||
{ id: 'contact', label: 'İletişim' },
|
||||
{ id: 'seo', label: 'SEO Ayarları' },
|
||||
{ id: 'scripts', label: 'Script & Analitik' },
|
||||
]
|
||||
|
||||
export function ContentForm({ initialContent }: ContentFormProps) {
|
||||
|
||||
187
components/dashboard/customer-form.tsx
Normal file
187
components/dashboard/customer-form.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Customer, CustomerInsert } from "@/types/customer"
|
||||
import { addCustomer, updateCustomer } from "@/lib/customers/actions"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Plus, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const formSchema = z.object({
|
||||
full_name: z.string().min(2, "Ad soyad en az 2 karakter olmalıdır"),
|
||||
email: z.string().email("Geçersiz e-posta adresi").or(z.literal("")).optional(),
|
||||
phone: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
interface CustomerFormProps {
|
||||
customer?: Customer
|
||||
trigger?: React.ReactNode
|
||||
}
|
||||
|
||||
export function CustomerForm({ customer, trigger }: CustomerFormProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
full_name: customer?.full_name || "",
|
||||
email: customer?.email || "",
|
||||
phone: customer?.phone || "",
|
||||
address: customer?.address || "",
|
||||
notes: customer?.notes || "",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const customerData = {
|
||||
...values,
|
||||
email: values.email || null,
|
||||
phone: values.phone || null,
|
||||
address: values.address || null,
|
||||
notes: values.notes || null,
|
||||
} as CustomerInsert
|
||||
|
||||
let result
|
||||
if (customer) {
|
||||
result = await updateCustomer(customer.id, customerData)
|
||||
} else {
|
||||
result = await addCustomer(customerData)
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.success(customer ? "Müşteri güncellendi" : "Müşteri eklendi")
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Bir hata oluştu")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" /> Yeni Müşteri
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{customer ? "Müşteriyi Düzenle" : "Yeni Müşteri Ekle"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Müşteri bilgilerini aşağıdan yönetebilirsiniz.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="full_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ad Soyad</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ad Soyad" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Posta</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ornek@site.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Telefon</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0555..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="address"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Adres</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Adres..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Özel Notlar</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea placeholder="Notlar..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Kaydet
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags } from "lucide-react"
|
||||
import { LayoutDashboard, Package, ShoppingCart, Settings, Globe, Tags, Users, MessageSquare, History } from "lucide-react"
|
||||
|
||||
const sidebarItems = [
|
||||
{
|
||||
@@ -16,15 +16,25 @@ const sidebarItems = [
|
||||
href: "/dashboard/products",
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: "Kategoriler",
|
||||
href: "/dashboard/categories",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: "Siparişler",
|
||||
href: "/dashboard/orders",
|
||||
icon: ShoppingCart,
|
||||
},
|
||||
{
|
||||
title: "Kategoriler",
|
||||
href: "/dashboard/categories",
|
||||
icon: Tags,
|
||||
title: "Müşteriler",
|
||||
href: "/dashboard/customers",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: "SMS Gönder",
|
||||
href: "/dashboard/sms",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
title: "Ayarlar",
|
||||
@@ -51,7 +61,7 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
Yönetim
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
{sidebarItems.map((item) => (
|
||||
{sidebarItems.filter(i => !i.href.includes('/sms')).map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
@@ -66,6 +76,34 @@ export function Sidebar({ className }: SidebarProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
SMS
|
||||
</h2>
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
href="/dashboard/sms"
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
||||
pathname === "/dashboard/sms" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Yeni SMS
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/sms/logs"
|
||||
className={cn(
|
||||
"flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors",
|
||||
pathname === "/dashboard/sms/logs" ? "bg-slate-100 dark:bg-slate-800 text-primary" : "text-slate-500 dark:text-slate-400"
|
||||
)}
|
||||
>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
Geçmiş
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
198
components/dashboard/sms-page-client.tsx
Normal file
198
components/dashboard/sms-page-client.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Customer } from "@/types/customer"
|
||||
import { sendBulkSms } from "@/lib/sms/actions"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { Loader2, Send } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
const formSchema = z.object({
|
||||
manualNumbers: z.string().optional(),
|
||||
message: z.string().min(1, "Mesaj içeriği boş olamaz").max(900, "Mesaj çok uzun (max 900 karakter)"),
|
||||
selectedCustomers: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
interface SmsPageProps {
|
||||
customers: Customer[]
|
||||
}
|
||||
|
||||
export default function SmsPageClient({ customers }: SmsPageProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectAll, setSelectAll] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
manualNumbers: "",
|
||||
message: "",
|
||||
selectedCustomers: []
|
||||
},
|
||||
})
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
setSelectAll(checked)
|
||||
if (checked) {
|
||||
const allPhones = customers.map(c => c.phone).filter(Boolean) as string[]
|
||||
form.setValue('selectedCustomers', allPhones)
|
||||
} else {
|
||||
form.setValue('selectedCustomers', [])
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const manualPhones = values.manualNumbers
|
||||
?.split(/[,\n]/) // Split by comma or newline
|
||||
.map(p => p.trim())
|
||||
.filter(p => p !== "") || []
|
||||
|
||||
const customerPhones = values.selectedCustomers || []
|
||||
const allPhones = [...manualPhones, ...customerPhones]
|
||||
|
||||
if (allPhones.length === 0) {
|
||||
toast.error("Lütfen en az bir alıcı seçin veya numara girin.")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await sendBulkSms(allPhones, values.message)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
form.reset()
|
||||
setSelectAll(false)
|
||||
} else {
|
||||
toast.error(result.error || "SMS gönderilirken hata oluştu")
|
||||
}
|
||||
} catch {
|
||||
toast.error("Bir hata oluştu")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const watchedSelected = form.watch("selectedCustomers") || []
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 p-8 pt-6">
|
||||
<h2 className="text-3xl font-bold tracking-tight">SMS Gönderimi</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mesaj Bilgileri</CardTitle>
|
||||
<CardDescription>
|
||||
Toplu veya tekil SMS gönderin. (Türkçe karakter desteklenir)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="sms-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Manuel Numaralar</Label>
|
||||
<Textarea
|
||||
placeholder="5551234567, 5329876543 (Virgül veya alt satır ile ayırın)"
|
||||
{...form.register("manualNumbers")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Veritabanında olmayan numaraları buraya girebilirsiniz.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Gönderilecek Mesaj</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px]"
|
||||
placeholder="Mesajınızı buraya yazın..."
|
||||
{...form.register("message")}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Türkçe karakterler otomatik desteklenir.</span>
|
||||
<span>{form.watch("message")?.length || 0} / 900</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-900 rounded-md">
|
||||
<h4 className="font-semibold mb-2">Özet</h4>
|
||||
<ul className="list-disc list-inside text-sm">
|
||||
<li>Manuel: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0)} Kişi</li>
|
||||
<li>Seçili Müşteri: {watchedSelected.length} Kişi</li>
|
||||
<li className="font-bold mt-1">Toplam: {(form.watch("manualNumbers")?.split(/[,\n]/).filter(x => x.trim()).length || 0) + watchedSelected.length} Kişi</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button form="sms-form" type="submit" disabled={loading} className="w-full">
|
||||
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
Gönderimi Başlat
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>Müşteri Listesi</CardTitle>
|
||||
<CardDescription>
|
||||
Listeden toplu seçim yapabilirsiniz.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-[400px]">
|
||||
<div className="flex items-center space-x-2 mb-4 pb-4 border-b">
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
<Label htmlFor="select-all" className="font-bold">Tümünü Seç ({customers.length})</Label>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px] w-full pr-4">
|
||||
<div className="space-y-2">
|
||||
{customers.length === 0 && <p className="text-muted-foreground">Kayıtlı müşteri bulunamadı.</p>}
|
||||
{customers.map((customer) => (
|
||||
<div key={customer.id} className="flex items-start space-x-2 py-2 hover:bg-slate-50 dark:hover:bg-slate-900 rounded px-2">
|
||||
<Checkbox
|
||||
id={`customer-${customer.id}`}
|
||||
checked={watchedSelected.includes(customer.phone || "")}
|
||||
disabled={!customer.phone}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = form.getValues("selectedCustomers") || []
|
||||
if (checked) {
|
||||
form.setValue("selectedCustomers", [...current, customer.phone || ""])
|
||||
} else {
|
||||
form.setValue("selectedCustomers", current.filter(p => p !== customer.phone))
|
||||
setSelectAll(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor={`customer-${customer.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{customer.full_name}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{customer.phone || "Telefon Yok"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,8 +88,26 @@ export async function Navbar() {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Mobile Logo (Visible only on mobile) */}
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2 md:hidden">
|
||||
{siteSettings.site_logo ? (
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src={siteSettings.site_logo}
|
||||
alt={siteSettings.site_title || "ParaKasa"}
|
||||
fill
|
||||
className="object-contain object-left"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-lg font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-slate-900 to-slate-500 dark:from-slate-100 dark:to-slate-400">
|
||||
{siteSettings.site_title || "PARAKASA"}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
||||
<div className="w-full flex-1 md:w-auto md:flex-none">
|
||||
<div className="w-full flex-1 md:w-auto md:flex-none hidden md:block">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -13,7 +13,7 @@ export async function submitContactForm(data: ContactFormValues) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
// In a real app, you would use Resend or Nodemailer here
|
||||
console.log("Contact Form Submitted:", result.data)
|
||||
|
||||
|
||||
return { success: true, message: "Mesajınız başarıyla gönderildi." }
|
||||
}
|
||||
|
||||
88
lib/customers/actions.ts
Normal file
88
lib/customers/actions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
'use server'
|
||||
|
||||
import { createClient } from "@/lib/supabase-server"
|
||||
import { Customer, CustomerInsert, CustomerUpdate } from "@/types/customer"
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
// Get all customers
|
||||
export async function getCustomers() {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching customers:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true, data: data as Customer[] }
|
||||
}
|
||||
|
||||
// Get customer by ID
|
||||
export async function getCustomerById(id: number) {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
return { success: true, data: data as Customer }
|
||||
}
|
||||
|
||||
// Add new customer
|
||||
export async function addCustomer(customer: CustomerInsert) {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert(customer)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/customers')
|
||||
return { success: true, data: data as Customer }
|
||||
}
|
||||
|
||||
// Update existing customer
|
||||
export async function updateCustomer(id: number, customer: CustomerUpdate) {
|
||||
const supabase = createClient()
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.update({ ...customer, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/customers')
|
||||
return { success: true, data: data as Customer }
|
||||
}
|
||||
|
||||
// Delete customer
|
||||
export async function deleteCustomer(id: number) {
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase
|
||||
.from('customers')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/customers')
|
||||
return { success: true }
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export async function updateSmsSettings(data: {
|
||||
|
||||
revalidatePath("/dashboard/settings")
|
||||
return { success: true }
|
||||
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
@@ -133,3 +134,70 @@ export async function sendTestSms(phone: string) {
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendBulkSms(phones: string[], message: string) {
|
||||
try {
|
||||
await assertAdmin()
|
||||
|
||||
// Fetch credentials
|
||||
const { data: settings } = await supabaseAdmin.from('sms_settings').select('*').single()
|
||||
if (!settings) throw new Error("SMS ayarları yapılmamış.")
|
||||
|
||||
const mobileService = new NetGsmService({
|
||||
username: settings.username,
|
||||
password: settings.password,
|
||||
header: settings.header,
|
||||
apiUrl: settings.api_url
|
||||
})
|
||||
|
||||
// Remove duplicates and empty
|
||||
const uniquePhones = Array.from(new Set(phones.filter(p => p && p.trim() !== '')))
|
||||
|
||||
const results = []
|
||||
|
||||
for (const phone of uniquePhones) {
|
||||
const result = await mobileService.sendSms(phone, message)
|
||||
|
||||
// Log result
|
||||
await supabaseAdmin.from('sms_logs').insert({
|
||||
phone,
|
||||
message,
|
||||
status: result.success ? 'success' : 'error',
|
||||
response_code: result.code || result.error
|
||||
})
|
||||
|
||||
results.push({ phone, ...result })
|
||||
}
|
||||
|
||||
const successCount = results.filter(r => r.success).length
|
||||
const total = uniquePhones.length
|
||||
|
||||
revalidatePath("/dashboard/sms")
|
||||
return {
|
||||
success: true,
|
||||
message: `${total} kişiden ${successCount} kişiye başarıyla gönderildi.`,
|
||||
details: results
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSmsLogs(limit: number = 50) {
|
||||
try {
|
||||
await assertAdmin()
|
||||
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from('sms_logs')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return { success: true, data }
|
||||
} catch (error) {
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
@@ -1248,6 +1249,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-scroll-area": {
|
||||
"version": "1.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz",
|
||||
"integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
94
security_updates.sql
Normal file
94
security_updates.sql
Normal file
@@ -0,0 +1,94 @@
|
||||
-- SECURITY UPDATES
|
||||
-- This script strengthens the RLS policies by enforcing 'admin' role checks
|
||||
-- instead of just checking if the user is authenticated.
|
||||
|
||||
-- 1. PRODUCTS TABLE
|
||||
-- Drop existing loose policies
|
||||
DROP POLICY IF EXISTS "Authenticated users can insert products." ON products;
|
||||
DROP POLICY IF EXISTS "Authenticated users can update products." ON products;
|
||||
DROP POLICY IF EXISTS "Authenticated users can delete products." ON products;
|
||||
|
||||
-- Create strict admin policies
|
||||
CREATE POLICY "Admins can insert products"
|
||||
ON products FOR INSERT
|
||||
WITH CHECK (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can update products"
|
||||
ON products FOR UPDATE
|
||||
USING (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can delete products"
|
||||
ON products FOR DELETE
|
||||
USING (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- 2. CUSTOMERS TABLE
|
||||
-- Drop existing loose policies (if they match the previous loose pattern)
|
||||
DROP POLICY IF EXISTS "Admins can insert customers" ON customers;
|
||||
DROP POLICY IF EXISTS "Admins can update customers" ON customers;
|
||||
DROP POLICY IF EXISTS "Admins can delete customers" ON customers;
|
||||
|
||||
-- Re-create strict policies (just to be sure, ensuring the subquery check is present)
|
||||
CREATE POLICY "Strict Admin Insert Customers"
|
||||
ON customers FOR INSERT
|
||||
WITH CHECK (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Strict Admin Update Customers"
|
||||
ON customers FOR UPDATE
|
||||
USING (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Strict Admin Delete Customers"
|
||||
ON customers FOR DELETE
|
||||
USING (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- 3. SITE CONTENTS TABLE
|
||||
DROP POLICY IF EXISTS "Admin update access" ON site_contents;
|
||||
DROP POLICY IF EXISTS "Admin insert access" ON site_contents;
|
||||
|
||||
CREATE POLICY "Strict Admin Update Site Contents"
|
||||
ON site_contents FOR UPDATE
|
||||
USING (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Strict Admin Insert Site Contents"
|
||||
ON site_contents FOR INSERT
|
||||
WITH CHECK (
|
||||
exists (
|
||||
select 1 from profiles
|
||||
where profiles.id = auth.uid() and profiles.role = 'admin'
|
||||
)
|
||||
);
|
||||
35
supabase_schema_customers.sql
Normal file
35
supabase_schema_customers.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Create customers table
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
full_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
|
||||
);
|
||||
|
||||
-- Enable Row Level Security
|
||||
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Policies
|
||||
-- 1. Admin read access
|
||||
CREATE POLICY "Admins can view customers"
|
||||
ON customers FOR SELECT
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 2. Admin insert access
|
||||
CREATE POLICY "Admins can insert customers"
|
||||
ON customers FOR INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
-- 3. Admin update access
|
||||
CREATE POLICY "Admins can update customers"
|
||||
ON customers FOR UPDATE
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- 4. Admin delete access
|
||||
CREATE POLICY "Admins can delete customers"
|
||||
ON customers FOR DELETE
|
||||
USING (auth.role() = 'authenticated');
|
||||
13
types/customer.ts
Normal file
13
types/customer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface Customer {
|
||||
id: number
|
||||
full_name: string
|
||||
email?: string | null
|
||||
phone?: string | null
|
||||
address?: string | null
|
||||
notes?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type CustomerInsert = Omit<Customer, 'id' | 'created_at' | 'updated_at'>
|
||||
export type CustomerUpdate = Partial<CustomerInsert>
|
||||
Reference in New Issue
Block a user