Spaces:
Running
Running
| import React, { useState } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| Calendar as CalendarIcon, | |
| ChevronLeft, | |
| ChevronRight, | |
| Plus, | |
| Clock, | |
| Layers, | |
| Image, | |
| FileText, | |
| Video, | |
| Filter, | |
| Settings, | |
| Sparkles, | |
| Check, | |
| X, | |
| GripVertical | |
| } from 'lucide-react'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Calendar } from '@/components/ui/calendar'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Slider } from '@/components/ui/slider'; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| } from '@/components/ui/dialog'; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from '@/components/ui/popover'; | |
| import { format, addDays, startOfWeek, endOfWeek, eachDayOfInterval, isSameDay, isToday } from 'date-fns'; | |
| import { Link } from 'react-router-dom'; | |
| import { createPageUrl } from '@/utils'; | |
| const products = [ | |
| { id: 'ocr', name: 'Document Parsing (OCR)', shortName: 'OCR', color: 'blue' }, | |
| { id: 'p2p', name: 'Purchase To Pay', shortName: 'P2P', color: 'emerald', subCategories: ['Budget Approval', 'Purchase Request', 'Accounts Payable'] }, | |
| { id: 'o2c', name: 'Order to Cash', shortName: 'O2C', color: 'violet', subCategories: ['Quotation', 'Sales Order', 'PickSlip Delivery', 'Accounts Receivable'] }, | |
| ]; | |
| const postTypes = [ | |
| { id: 'carousel', name: 'Carousel', icon: Layers, description: 'Multi-slide visual story' }, | |
| { id: 'cover_content', name: 'Cover Image + Content', icon: Image, description: 'Featured image with text' }, | |
| { id: 'content_only', name: 'Content Only', icon: FileText, description: 'Text-based post' }, | |
| { id: 'webinar', name: 'Webinar Invite', icon: Video, description: 'Event promotion' }, | |
| ]; | |
| const scheduledPosts = [ | |
| { id: 1, title: 'OCR Automation Benefits', type: 'carousel', product: 'ocr', time: '10:00 AM', date: new Date(), status: 'scheduled' }, | |
| { id: 2, title: 'P2P Efficiency Guide', type: 'cover_content', product: 'p2p', time: '2:00 PM', date: new Date(), status: 'draft' }, | |
| { id: 3, title: 'O2C Webinar', type: 'webinar', product: 'o2c', time: '11:00 AM', date: addDays(new Date(), 1), status: 'scheduled' }, | |
| { id: 4, title: 'Invoice Processing Tips', type: 'content_only', product: 'ocr', time: '3:00 PM', date: addDays(new Date(), 1), status: 'scheduled' }, | |
| { id: 5, title: 'Budget Approval Flow', type: 'carousel', product: 'p2p', time: '9:00 AM', date: addDays(new Date(), 2), status: 'scheduled' }, | |
| { id: 6, title: 'Sales Order Demo', type: 'cover_content', product: 'o2c', time: '1:00 PM', date: addDays(new Date(), 3), status: 'draft' }, | |
| ]; | |
| export default function Scheduler() { | |
| const [selectedDate, setSelectedDate] = useState(new Date()); | |
| const [currentWeekStart, setCurrentWeekStart] = useState(startOfWeek(new Date(), { weekStartsOn: 1 })); | |
| const [campaignDialogOpen, setCampaignDialogOpen] = useState(false); | |
| const [selectedProducts, setSelectedProducts] = useState(['ocr', 'p2p', 'o2c']); | |
| const [selectedPostTypes, setSelectedPostTypes] = useState(['carousel', 'cover_content']); | |
| const [dateRange, setDateRange] = useState({ from: new Date(), to: addDays(new Date(), 30) }); | |
| const [postsPerWeek, setPostsPerWeek] = useState([5]); | |
| const weekDays = eachDayOfInterval({ | |
| start: currentWeekStart, | |
| end: endOfWeek(currentWeekStart, { weekStartsOn: 1 }) | |
| }); | |
| const navigateWeek = (direction) => { | |
| setCurrentWeekStart(prev => addDays(prev, direction === 'next' ? 7 : -7)); | |
| }; | |
| const getPostsForDate = (date) => { | |
| return scheduledPosts.filter(post => isSameDay(post.date, date)); | |
| }; | |
| const getProductColor = (productId) => { | |
| const product = products.find(p => p.id === productId); | |
| return product?.color || 'slate'; | |
| }; | |
| const getPostTypeIcon = (typeId) => { | |
| const type = postTypes.find(t => t.id === typeId); | |
| return type?.icon || FileText; | |
| }; | |
| const toggleProduct = (productId) => { | |
| setSelectedProducts(prev => | |
| prev.includes(productId) | |
| ? prev.filter(id => id !== productId) | |
| : [...prev, productId] | |
| ); | |
| }; | |
| const togglePostType = (typeId) => { | |
| setSelectedPostTypes(prev => | |
| prev.includes(typeId) | |
| ? prev.filter(id => id !== typeId) | |
| : [...prev, typeId] | |
| ); | |
| }; | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-blue-50/30"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
| {/* Header */} | |
| <motion.div | |
| initial={{ opacity: 0, y: -20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="mb-8" | |
| > | |
| <div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> | |
| <div> | |
| <h1 className="text-3xl font-bold text-slate-900 tracking-tight"> | |
| Content Scheduler | |
| </h1> | |
| <p className="text-slate-500 mt-1"> | |
| Plan and schedule your LinkedIn content calendar | |
| </p> | |
| </div> | |
| <div className="flex gap-3"> | |
| <Dialog open={campaignDialogOpen} onOpenChange={setCampaignDialogOpen}> | |
| <DialogTrigger asChild> | |
| <Button variant="outline" className="gap-2 border-slate-200"> | |
| <Settings className="w-4 h-4" /> | |
| Campaign Settings | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center gap-2"> | |
| <Sparkles className="w-5 h-5 text-amber-500" /> | |
| Configure Campaign | |
| </DialogTitle> | |
| </DialogHeader> | |
| <div className="space-y-6 pt-4"> | |
| {/* Date Range */} | |
| <div className="space-y-3"> | |
| <Label className="text-sm font-semibold">Campaign Date Range</Label> | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <Label className="text-xs text-slate-500">Start Date</Label> | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <Button variant="outline" className="w-full justify-start mt-1.5"> | |
| <CalendarIcon className="w-4 h-4 mr-2" /> | |
| {format(dateRange.from, 'MMM d, yyyy')} | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0" align="start"> | |
| <Calendar | |
| mode="single" | |
| selected={dateRange.from} | |
| onSelect={(date) => setDateRange(prev => ({ ...prev, from: date }))} | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| </div> | |
| <div> | |
| <Label className="text-xs text-slate-500">End Date</Label> | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <Button variant="outline" className="w-full justify-start mt-1.5"> | |
| <CalendarIcon className="w-4 h-4 mr-2" /> | |
| {format(dateRange.to, 'MMM d, yyyy')} | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0" align="start"> | |
| <Calendar | |
| mode="single" | |
| selected={dateRange.to} | |
| onSelect={(date) => setDateRange(prev => ({ ...prev, to: date }))} | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Products to Focus */} | |
| <div className="space-y-3"> | |
| <Label className="text-sm font-semibold">Products to Focus</Label> | |
| <div className="grid gap-3"> | |
| {products.map(product => ( | |
| <div key={product.id} className="space-y-2"> | |
| <div | |
| onClick={() => toggleProduct(product.id)} | |
| className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${ | |
| selectedProducts.includes(product.id) | |
| ? `border-${product.color}-300 bg-${product.color}-50` | |
| : 'border-slate-200 hover:border-slate-300' | |
| }`} | |
| > | |
| <div className={`w-8 h-8 rounded-lg flex items-center justify-center ${ | |
| selectedProducts.includes(product.id) | |
| ? `bg-${product.color}-500 text-white` | |
| : 'bg-slate-100 text-slate-400' | |
| }`}> | |
| {selectedProducts.includes(product.id) ? ( | |
| <Check className="w-4 h-4" /> | |
| ) : ( | |
| <Plus className="w-4 h-4" /> | |
| )} | |
| </div> | |
| <div className="flex-1"> | |
| <p className="font-medium text-slate-900">{product.name}</p> | |
| {product.subCategories && ( | |
| <p className="text-xs text-slate-500 mt-0.5"> | |
| {product.subCategories.join(', ')} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Post Types Mix */} | |
| <div className="space-y-3"> | |
| <Label className="text-sm font-semibold">Post Types Mix</Label> | |
| <div className="grid grid-cols-2 gap-3"> | |
| {postTypes.map(type => ( | |
| <div | |
| key={type.id} | |
| onClick={() => togglePostType(type.id)} | |
| className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${ | |
| selectedPostTypes.includes(type.id) | |
| ? 'border-blue-300 bg-blue-50' | |
| : 'border-slate-200 hover:border-slate-300' | |
| }`} | |
| > | |
| <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${ | |
| selectedPostTypes.includes(type.id) | |
| ? 'bg-blue-500 text-white' | |
| : 'bg-slate-100 text-slate-400' | |
| }`}> | |
| <type.icon className="w-5 h-5" /> | |
| </div> | |
| <div> | |
| <p className="font-medium text-sm text-slate-900">{type.name}</p> | |
| <p className="text-xs text-slate-500">{type.description}</p> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Posting Frequency */} | |
| <div className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <Label className="text-sm font-semibold">Posts per Week</Label> | |
| <span className="text-lg font-bold text-blue-600">{postsPerWeek[0]}</span> | |
| </div> | |
| <Slider | |
| value={postsPerWeek} | |
| onValueChange={setPostsPerWeek} | |
| min={1} | |
| max={14} | |
| step={1} | |
| className="py-4" | |
| /> | |
| <div className="flex justify-between text-xs text-slate-500"> | |
| <span>1 post</span> | |
| <span>14 posts</span> | |
| </div> | |
| </div> | |
| <div className="flex justify-end gap-2 pt-4 border-t"> | |
| <Button variant="outline" onClick={() => setCampaignDialogOpen(false)}> | |
| Cancel | |
| </Button> | |
| <Button className="bg-gradient-to-r from-blue-600 to-indigo-600 gap-2"> | |
| <Sparkles className="w-4 h-4" /> | |
| Generate Schedule | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| <Link to={createPageUrl('PostEditor')}> | |
| <Button className="gap-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 shadow-lg shadow-blue-500/25"> | |
| <Plus className="w-4 h-4" /> | |
| Create Post | |
| </Button> | |
| </Link> | |
| </div> | |
| </div> | |
| </motion.div> | |
| <div className="grid lg:grid-cols-4 gap-6"> | |
| {/* Calendar Sidebar */} | |
| <motion.div | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| className="lg:col-span-1 space-y-6" | |
| > | |
| <Card className="border-0 shadow-lg shadow-slate-200/50"> | |
| <CardContent className="p-4"> | |
| <Calendar | |
| mode="single" | |
| selected={selectedDate} | |
| onSelect={setSelectedDate} | |
| className="rounded-md" | |
| /> | |
| </CardContent> | |
| </Card> | |
| {/* Quick Stats */} | |
| <Card className="border-0 shadow-lg shadow-slate-200/50"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-sm font-semibold text-slate-600">This Week</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-slate-600">Scheduled</span> | |
| <Badge className="bg-blue-100 text-blue-700 border-0">12 posts</Badge> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-slate-600">Drafts</span> | |
| <Badge className="bg-amber-100 text-amber-700 border-0">4 posts</Badge> | |
| </div> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm text-slate-600">Published</span> | |
| <Badge className="bg-emerald-100 text-emerald-700 border-0">8 posts</Badge> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Product Filter */} | |
| <Card className="border-0 shadow-lg shadow-slate-200/50"> | |
| <CardHeader className="pb-3"> | |
| <CardTitle className="text-sm font-semibold text-slate-600 flex items-center gap-2"> | |
| <Filter className="w-4 h-4" /> | |
| Filter by Product | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-2"> | |
| {products.map(product => ( | |
| <label key={product.id} className="flex items-center gap-3 cursor-pointer"> | |
| <Checkbox defaultChecked /> | |
| <span className="text-sm text-slate-700">{product.shortName}</span> | |
| <div className={`w-2 h-2 rounded-full bg-${product.color}-500 ml-auto`} /> | |
| </label> | |
| ))} | |
| </CardContent> | |
| </Card> | |
| </motion.div> | |
| {/* Week View */} | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| className="lg:col-span-3" | |
| > | |
| <Card className="border-0 shadow-lg shadow-slate-200/50"> | |
| <CardHeader className="pb-4 border-b border-slate-100"> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="text-lg font-semibold"> | |
| {format(currentWeekStart, 'MMMM yyyy')} | |
| </CardTitle> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| onClick={() => navigateWeek('prev')} | |
| className="h-8 w-8" | |
| > | |
| <ChevronLeft className="w-4 h-4" /> | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }))} | |
| > | |
| Today | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| onClick={() => navigateWeek('next')} | |
| className="h-8 w-8" | |
| > | |
| <ChevronRight className="w-4 h-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-0"> | |
| <div className="grid grid-cols-7 border-b border-slate-100"> | |
| {weekDays.map((day, index) => ( | |
| <div | |
| key={index} | |
| className={`text-center py-4 border-r last:border-r-0 border-slate-100 ${ | |
| isToday(day) ? 'bg-blue-50' : '' | |
| }`} | |
| > | |
| <p className="text-xs text-slate-500 uppercase"> | |
| {format(day, 'EEE')} | |
| </p> | |
| <p className={`text-lg font-semibold mt-1 ${ | |
| isToday(day) ? 'text-blue-600' : 'text-slate-900' | |
| }`}> | |
| {format(day, 'd')} | |
| </p> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="grid grid-cols-7 min-h-[400px]"> | |
| {weekDays.map((day, dayIndex) => { | |
| const dayPosts = getPostsForDate(day); | |
| return ( | |
| <div | |
| key={dayIndex} | |
| className={`border-r last:border-r-0 border-slate-100 p-2 ${ | |
| isToday(day) ? 'bg-blue-50/30' : '' | |
| }`} | |
| > | |
| <AnimatePresence> | |
| {dayPosts.map((post, postIndex) => { | |
| const PostIcon = getPostTypeIcon(post.type); | |
| const color = getProductColor(post.product); | |
| return ( | |
| <motion.div | |
| key={post.id} | |
| initial={{ opacity: 0, scale: 0.9 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.9 }} | |
| transition={{ delay: postIndex * 0.05 }} | |
| > | |
| <Link to={createPageUrl('PostEditor') + `?id=${post.id}`}> | |
| <div className={`mb-2 p-2 rounded-lg bg-white border border-${color}-200 hover:border-${color}-300 shadow-sm hover:shadow-md transition-all cursor-pointer group`}> | |
| <div className="flex items-center gap-1.5 mb-1"> | |
| <div className={`w-5 h-5 rounded flex items-center justify-center bg-${color}-100`}> | |
| <PostIcon className={`w-3 h-3 text-${color}-600`} /> | |
| </div> | |
| <span className="text-[10px] text-slate-500">{post.time}</span> | |
| </div> | |
| <p className="text-xs font-medium text-slate-800 line-clamp-2 leading-tight"> | |
| {post.title} | |
| </p> | |
| <div className="flex items-center justify-between mt-1.5"> | |
| <Badge className={`text-[9px] px-1 py-0 bg-${color}-100 text-${color}-700 border-0`}> | |
| {products.find(p => p.id === post.product)?.shortName} | |
| </Badge> | |
| {post.status === 'draft' && ( | |
| <span className="text-[9px] text-amber-600">Draft</span> | |
| )} | |
| </div> | |
| </div> | |
| </Link> | |
| </motion.div> | |
| ); | |
| })} | |
| </AnimatePresence> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="w-full h-7 text-xs text-slate-400 hover:text-slate-600 opacity-0 hover:opacity-100" | |
| > | |
| <Plus className="w-3 h-3 mr-1" /> | |
| Add | |
| </Button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </motion.div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |