Files
parakasa/components/dashboard/sms-page-client.tsx
2026-01-30 00:24:40 +03:00

481 lines
24 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect } 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 { getTemplates, createTemplate, deleteTemplate, SmsTemplate } from "@/lib/sms/templates"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Loader2, Send, Save, Trash2, BookOpen, Smartphone } 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 [templates, setTemplates] = useState<SmsTemplate[]>([])
// Template Management States
const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false)
const [newTemplateTitle, setNewTemplateTitle] = useState("")
const [newTemplateMessage, setNewTemplateMessage] = useState("")
const [templateLoading, setTemplateLoading] = useState(false)
// Contact Picker States
const [isContactModalOpen, setIsContactModalOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
manualNumbers: "",
message: "",
selectedCustomers: []
},
})
async function handleNativeContactPicker() {
if (!('contacts' in navigator && 'ContactsManager' in window)) {
toast.error("Rehber özelliği desteklenmiyor (HTTPS gerekli olabilir).")
return
}
try {
const props = ['tel'];
const opts = { multiple: true };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const contacts = await (navigator as any).contacts.select(props, opts);
if (contacts && contacts.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const newNumbers = contacts.map((contact: any) => {
const phone = contact.tel?.[0]
return phone ? phone.replace(/\s/g, '') : null;
}).filter(Boolean).join(", ");
if (newNumbers) {
const current = form.getValues("manualNumbers");
const updated = current ? `${current}, ${newNumbers}` : newNumbers;
form.setValue("manualNumbers", updated);
toast.success(`${contacts.length} numara eklendi.`);
}
}
} catch (ex) {
console.error(ex);
}
}
// Load templates on mount
useEffect(() => {
loadTemplates()
}, [])
async function loadTemplates() {
const result = await getTemplates()
if (result.success && result.data) {
setTemplates(result.data)
}
}
async function handleSaveTemplate() {
if (!newTemplateTitle || !newTemplateMessage) {
toast.error("Başlık ve mesaj zorunludur")
return
}
setTemplateLoading(true)
const result = await createTemplate(newTemplateTitle, newTemplateMessage)
setTemplateLoading(false)
if (result.success) {
toast.success("Şablon kaydedildi")
setNewTemplateTitle("")
setNewTemplateMessage("")
setIsTemplateModalOpen(false)
loadTemplates()
} else {
toast.error(result.error || "Şablon kaydedilemedi")
}
}
async function handleDeleteTemplate(id: string) {
if (!confirm("Bu şablonu silmek istediğinize emin misiniz?")) return
const result = await deleteTemplate(id)
if (result.success) {
toast.success("Şablon silindi")
loadTemplates()
} else {
toast.error("Şablon silinemedi")
}
}
const handleSelectTemplate = (id: string) => {
const template = templates.find(t => t.id === id)
if (template) {
form.setValue("message", template.message)
}
}
// Filter customers for contact picker
const filteredCustomers = customers.filter(c =>
c.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.phone?.includes(searchTerm)
)
const toggleCustomerSelection = (phone: string) => {
const current = form.getValues("selectedCustomers") || []
if (current.includes(phone)) {
form.setValue("selectedCustomers", current.filter(p => p !== phone))
} else {
form.setValue("selectedCustomers", [...current, phone])
}
}
const selectAllFiltered = () => {
const current = form.getValues("selectedCustomers") || []
const newPhones = filteredCustomers.map(c => c.phone).filter(Boolean) as string[]
// Merge unique
const merged = Array.from(new Set([...current, ...newPhones]))
form.setValue("selectedCustomers", merged)
}
const deselectAll = () => {
form.setValue("selectedCustomers", [])
}
async function onSubmit(values: z.infer<typeof formSchema>) {
const manualPhones = values.manualNumbers
?.split(/[,\n]/)
.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({
manualNumbers: "",
message: "",
selectedCustomers: []
})
} 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-4 pt-6 md:p-8">
<h2 className="text-2xl md:text-3xl font-bold tracking-tight">SMS Gönderimi</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="md:col-span-2 lg:col-span-1">
<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">
{/* Templates Section */}
<div className="flex items-end gap-2">
<div className="flex-1 space-y-2">
<Label>Hazır Şablonlar</Label>
<Select onValueChange={handleSelectTemplate}>
<SelectTrigger>
<SelectValue placeholder="Şablon seçin..." />
</SelectTrigger>
<SelectContent>
{templates.map(t => (
<SelectItem key={t.id} value={t.id}>{t.title}</SelectItem>
))}
{templates.length === 0 && <div className="p-2 text-sm text-muted-foreground">Henüz şablon yok</div>}
</SelectContent>
</Select>
</div>
<Dialog open={isTemplateModalOpen} onOpenChange={setIsTemplateModalOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="icon">
<Save className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Yeni SMS Şablonu</DialogTitle>
<DialogDescription>
Sık kullandığınız mesajları şablon olarak kaydedin.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Şablon Adı</Label>
<Input
placeholder="Örn: Bayram Kutlaması"
value={newTemplateTitle}
onChange={e => setNewTemplateTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Mesaj İçeriği</Label>
<Textarea
placeholder="Mesajınız..."
value={newTemplateMessage}
onChange={e => setNewTemplateMessage(e.target.value)}
/>
</div>
{templates.length > 0 && (
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-2">Kayıtlı Şablonlar</h4>
<ScrollArea className="h-32 rounded border p-2">
{templates.map(t => (
<div key={t.id} className="flex items-center justify-between text-sm py-1">
<span>{t.title}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-500"
onClick={() => handleDeleteTemplate(t.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsTemplateModalOpen(false)}>İptal</Button>
<Button type="button" onClick={handleSaveTemplate} disabled={templateLoading}>Kaydet</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-2">
<FormLabel label="Gönderilecek Mesaj" />
<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="space-y-2">
<div className="flex items-center justify-between">
<Label>Manuel Numaralar</Label>
<Button
type="button"
variant="default"
onClick={handleNativeContactPicker}
>
<Smartphone className="mr-2 h-4 w-4" />
Telefondan Seç
</Button>
</div>
<Textarea
placeholder="5551234567 (Her satıra bir numara)"
className="min-h-[80px]"
{...form.register("manualNumbers")}
/>
</div>
<div className="pt-2">
<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>
{/* Contact Picker Section */}
<Card className="md:col-span-2 lg:col-span-1 h-fit">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Müşteri Rehberi</span>
<div className="flex gap-2">
<Dialog open={isContactModalOpen} onOpenChange={setIsContactModalOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Rehberi
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Müşteri Seçimi</DialogTitle>
<DialogDescription>
Listeden SMS göndermek istediğiniz müşterileri seçin.
</DialogDescription>
</DialogHeader>
<div className="flex items-center space-x-2 py-4">
<Input
placeholder="İsim veya telefon ile ara..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 mb-2">
<Button size="sm" variant="outline" onClick={selectAllFiltered}>Görünenleri Seç</Button>
<Button size="sm" variant="ghost" onClick={deselectAll}>Temizle</Button>
</div>
<ScrollArea className="flex-1 pr-4">
<div className="space-y-2">
{filteredCustomers.length === 0 && <p className="text-center text-muted-foreground py-4">Sonuç bulunamadı.</p>}
{filteredCustomers.map((customer) => (
<div
key={customer.id}
className="flex items-center space-x-3 p-3 rounded-lg border hover:bg-slate-50 dark:hover:bg-slate-900 cursor-pointer transition-colors"
onClick={() => toggleCustomerSelection(customer.phone || "")}
>
<Checkbox
checked={watchedSelected.includes(customer.phone || "")}
onCheckedChange={() => { }} // Handle by parent div click
id={`modal-customer-${customer.id}`}
/>
<div className="flex-1">
<div className="font-medium">{customer.full_name}</div>
<div className="text-sm text-muted-foreground">{customer.phone}</div>
</div>
</div>
))}
</div>
</ScrollArea>
<DialogFooter className="mt-4">
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">
{watchedSelected.length} kişi seçildi
</span>
<Button onClick={() => setIsContactModalOpen(false)}>Tamam</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardTitle>
<CardDescription>
Hızlı seçim veya detaylı arama yapın.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Input
placeholder="Hızlı ara..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<ScrollArea className="h-[400px] w-full pr-4 border rounded-md p-2">
<div className="space-y-2">
{filteredCustomers.map((customer) => (
<div
key={customer.id}
className="flex items-center space-x-3 p-2 rounded hover:bg-slate-50 dark:hover:bg-slate-900 cursor-pointer"
onClick={() => toggleCustomerSelection(customer.phone || "")}
>
<Checkbox
checked={watchedSelected.includes(customer.phone || "")}
id={`list-customer-${customer.id}`}
className="mt-0.5"
/>
<div className="grid gap-0.5 leading-none">
<label
htmlFor={`list-customer-${customer.id}`}
className="font-medium cursor-pointer"
>
{customer.full_name}
</label>
<p className="text-xs text-muted-foreground">
{customer.phone || "Telefon Yok"}
</p>
</div>
</div>
))}
{filteredCustomers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">Sonuç yok.</p>
)}
</div>
</ScrollArea>
</div>
</CardContent>
</Card>
</div>
</div>
)
}
function FormLabel({ label }: { label: string }) {
return <Label>{label}</Label>
}