Add multi-image support for task completions and documents
- Add TaskCompletionImage and DocumentImage models with one-to-many relationships
- Update admin panel to display images for completions and documents
- Add image arrays to API request/response DTOs
- Update repositories with Preload("Images") for eager loading
- Fix seed SQL execution to use raw SQL instead of prepared statements
- Fix table names in seed file (admin_users, push_notifications_*)
- Add comprehensive seed test data with 34 completion images and 24 document images
- Add subscription limitations admin feature with toggle
- Update admin sidebar with limitations link
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil } from 'lucide-react';
|
||||
import { ArrowLeft, Trash2, ExternalLink, Calendar, DollarSign, User, ClipboardList, Building2, Pencil, Star } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { completionsApi } from '@/lib/api';
|
||||
@@ -188,6 +188,26 @@ export default function CompletionDetailPage() {
|
||||
<span className="text-muted-foreground">Not recorded</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Rating</div>
|
||||
{completion.rating ? (
|
||||
<div className="flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Star
|
||||
key={star}
|
||||
className={`h-5 w-5 ${
|
||||
star <= completion.rating!
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm">({completion.rating}/5)</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Not rated</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">Record Created</div>
|
||||
<div>{new Date(completion.created_at).toLocaleString()}</div>
|
||||
@@ -210,33 +230,40 @@ export default function CompletionDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photo */}
|
||||
{completion.photo_url && (
|
||||
{/* Photos */}
|
||||
{completion.images && completion.images.length > 0 && (
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Photo</CardTitle>
|
||||
<CardTitle>Completion Photos ({completion.images.length})</CardTitle>
|
||||
<CardDescription>Photo evidence of task completion</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="relative max-w-2xl mx-auto">
|
||||
<img
|
||||
src={completion.photo_url}
|
||||
alt="Completion photo"
|
||||
className="rounded-lg border object-contain w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={completion.photo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open full size in new tab
|
||||
</a>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{completion.images.map((image, index) => (
|
||||
<div key={image.id || index} className="space-y-2">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || `Completion photo ${index + 1}`}
|
||||
className="rounded-lg border object-contain w-full"
|
||||
/>
|
||||
</div>
|
||||
{image.caption && (
|
||||
<p className="text-sm text-muted-foreground text-center">{image.caption}</p>
|
||||
)}
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href={image.image_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open full size
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -252,34 +252,43 @@ export default function CompletionsPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{completion.photo_url ? (
|
||||
{completion.images && completion.images.length > 0 ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ImageIcon className="h-4 w-4 mr-1" />
|
||||
View
|
||||
{completion.images.length} {completion.images.length === 1 ? 'Photo' : 'Photos'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Completion Photo</DialogTitle>
|
||||
<DialogTitle>Completion Photos ({completion.images.length})</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={completion.photo_url}
|
||||
alt="Completion photo"
|
||||
className="object-contain w-full h-full rounded-lg"
|
||||
/>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
{completion.images.map((image, index) => (
|
||||
<div key={image.id || index} className="space-y-2">
|
||||
<div className="relative aspect-video">
|
||||
<img
|
||||
src={image.image_url}
|
||||
alt={image.caption || `Completion photo ${index + 1}`}
|
||||
className="object-contain w-full h-full rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
{image.caption && (
|
||||
<p className="text-sm text-muted-foreground">{image.caption}</p>
|
||||
)}
|
||||
<a
|
||||
href={image.image_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
href={completion.photo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { ArrowLeft, Trash2, FileText, ExternalLink, Pencil, ImageIcon, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { documentsApi } from '@/lib/api';
|
||||
@@ -16,12 +18,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export function DocumentDetailClient() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const documentId = Number(params.id);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const { data: document, isLoading, error } = useQuery({
|
||||
queryKey: ['document', documentId],
|
||||
@@ -181,6 +188,68 @@ export function DocumentDetailClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Document Images */}
|
||||
{document.images && document.images.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-5 w-5" />
|
||||
Images ({document.images.length})
|
||||
</CardTitle>
|
||||
<CardDescription>Photos and images attached to this document</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{document.images.map((image) => (
|
||||
<div
|
||||
key={image.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden border cursor-pointer hover:ring-2 hover:ring-primary transition-all"
|
||||
onClick={() => setSelectedImage(image.image_url)}
|
||||
>
|
||||
<Image
|
||||
src={image.image_url}
|
||||
alt={image.caption || 'Document image'}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
|
||||
/>
|
||||
{image.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs p-1 truncate">
|
||||
{image.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Image Modal */}
|
||||
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
|
||||
<DialogContent className="max-w-4xl p-0 overflow-hidden">
|
||||
{selectedImage && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 z-10 bg-black/50 hover:bg-black/70 text-white"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Image
|
||||
src={selectedImage}
|
||||
alt="Document image"
|
||||
width={1200}
|
||||
height={800}
|
||||
className="w-full h-auto max-h-[80vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Shield, Layers, Infinity } from 'lucide-react';
|
||||
|
||||
import { limitationsApi, type TierLimits } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface TierLimitsFormData {
|
||||
properties_limit: string;
|
||||
tasks_limit: string;
|
||||
contractors_limit: string;
|
||||
documents_limit: string;
|
||||
}
|
||||
|
||||
function TierLimitsCard({
|
||||
tier,
|
||||
limits,
|
||||
onSave
|
||||
}: {
|
||||
tier: 'free' | 'pro';
|
||||
limits?: TierLimits;
|
||||
onSave: (tier: 'free' | 'pro', data: TierLimitsFormData) => void;
|
||||
}) {
|
||||
const [formData, setFormData] = useState<TierLimitsFormData>({
|
||||
properties_limit: '',
|
||||
tasks_limit: '',
|
||||
contractors_limit: '',
|
||||
documents_limit: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (limits) {
|
||||
setFormData({
|
||||
properties_limit: limits.properties_limit?.toString() ?? '',
|
||||
tasks_limit: limits.tasks_limit?.toString() ?? '',
|
||||
contractors_limit: limits.contractors_limit?.toString() ?? '',
|
||||
documents_limit: limits.documents_limit?.toString() ?? '',
|
||||
});
|
||||
}
|
||||
}, [limits]);
|
||||
|
||||
const handleChange = (field: keyof TierLimitsFormData, value: string) => {
|
||||
// Only allow numbers or empty string
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 capitalize">
|
||||
<Layers className="h-5 w-5" />
|
||||
{tier} Tier Limits
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{tier === 'free'
|
||||
? 'Set resource limits for free tier users. Leave empty for unlimited.'
|
||||
: 'Set resource limits for Pro tier users. Typically left empty (unlimited).'
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${tier}-properties`}>Properties Limit</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${tier}-properties`}
|
||||
type="text"
|
||||
placeholder="Unlimited"
|
||||
value={formData.properties_limit}
|
||||
onChange={(e) => handleChange('properties_limit', e.target.value)}
|
||||
/>
|
||||
{formData.properties_limit === '' && (
|
||||
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${tier}-tasks`}>Tasks Limit</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${tier}-tasks`}
|
||||
type="text"
|
||||
placeholder="Unlimited"
|
||||
value={formData.tasks_limit}
|
||||
onChange={(e) => handleChange('tasks_limit', e.target.value)}
|
||||
/>
|
||||
{formData.tasks_limit === '' && (
|
||||
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${tier}-contractors`}>Contractors Limit</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${tier}-contractors`}
|
||||
type="text"
|
||||
placeholder="Unlimited"
|
||||
value={formData.contractors_limit}
|
||||
onChange={(e) => handleChange('contractors_limit', e.target.value)}
|
||||
/>
|
||||
{formData.contractors_limit === '' && (
|
||||
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${tier}-documents`}>Documents Limit</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`${tier}-documents`}
|
||||
type="text"
|
||||
placeholder="Unlimited"
|
||||
value={formData.documents_limit}
|
||||
onChange={(e) => handleChange('documents_limit', e.target.value)}
|
||||
/>
|
||||
{formData.documents_limit === '' && (
|
||||
<Infinity className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onSave(tier, formData)}
|
||||
className="w-full"
|
||||
>
|
||||
Save {tier.charAt(0).toUpperCase() + tier.slice(1)} Tier Limits
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LimitationsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading: settingsLoading } = useQuery({
|
||||
queryKey: ['limitations-settings'],
|
||||
queryFn: limitationsApi.getSettings,
|
||||
});
|
||||
|
||||
const { data: tierLimits, isLoading: limitsLoading } = useQuery({
|
||||
queryKey: ['tier-limits'],
|
||||
queryFn: limitationsApi.listTierLimits,
|
||||
});
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: limitationsApi.updateSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['limitations-settings'] });
|
||||
toast.success('Limitations settings updated');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update limitations settings');
|
||||
},
|
||||
});
|
||||
|
||||
const updateTierLimitsMutation = useMutation({
|
||||
mutationFn: ({ tier, data }: { tier: 'free' | 'pro'; data: TierLimitsFormData }) => {
|
||||
return limitationsApi.updateTierLimits(tier, {
|
||||
properties_limit: data.properties_limit === '' ? null : parseInt(data.properties_limit),
|
||||
tasks_limit: data.tasks_limit === '' ? null : parseInt(data.tasks_limit),
|
||||
contractors_limit: data.contractors_limit === '' ? null : parseInt(data.contractors_limit),
|
||||
documents_limit: data.documents_limit === '' ? null : parseInt(data.documents_limit),
|
||||
});
|
||||
},
|
||||
onSuccess: (_, { tier }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tier-limits'] });
|
||||
toast.success(`${tier.charAt(0).toUpperCase() + tier.slice(1)} tier limits updated`);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update tier limits');
|
||||
},
|
||||
});
|
||||
|
||||
const handleLimitationsToggle = () => {
|
||||
if (settings) {
|
||||
updateSettingsMutation.mutate({
|
||||
enable_limitations: !settings.enable_limitations,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTierLimits = (tier: 'free' | 'pro', data: TierLimitsFormData) => {
|
||||
updateTierLimitsMutation.mutate({ tier, data });
|
||||
};
|
||||
|
||||
const freeLimits = tierLimits?.find(l => l.tier === 'free');
|
||||
const proLimits = tierLimits?.find(l => l.tier === 'pro');
|
||||
|
||||
if (settingsLoading || limitsLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Subscription Limitations</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure tier-based resource limits for users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enable Limitations Toggle */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Enable Limitations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether tier-based limitations are enforced for users. When disabled, all users have full access regardless of their subscription tier.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enable-limitations">Enforce Tier Limitations</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{settings?.enable_limitations
|
||||
? 'Limitations are currently ENABLED. Free tier users have restricted access.'
|
||||
: 'Limitations are currently DISABLED. All users have full access.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-limitations"
|
||||
checked={settings?.enable_limitations ?? false}
|
||||
onCheckedChange={handleLimitationsToggle}
|
||||
disabled={updateSettingsMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tier Limits */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<TierLimitsCard
|
||||
tier="free"
|
||||
limits={freeLimits}
|
||||
onSave={handleSaveTierLimits}
|
||||
/>
|
||||
<TierLimitsCard
|
||||
tier="pro"
|
||||
limits={proLimits}
|
||||
onSave={handleSaveTierLimits}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>How limits work:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
<li>Empty fields mean unlimited access for that resource</li>
|
||||
<li>A value of 0 means no access (blocked)</li>
|
||||
<li>Limits only apply when "Enable Limitations" is turned ON</li>
|
||||
<li>Pro tier users typically have unlimited access</li>
|
||||
<li>Changes take effect immediately for all users</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Sparkles, Plus, Pencil, Trash2, Eye, EyeOff, Code } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { limitationsApi, type UpgradeTrigger, type TriggerKeyOption, type CreateUpgradeTriggerRequest, type UpdateUpgradeTriggerRequest } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TriggerFormData {
|
||||
trigger_key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
promo_html: string;
|
||||
button_text: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const emptyFormData: TriggerFormData = {
|
||||
trigger_key: '',
|
||||
title: '',
|
||||
message: '',
|
||||
promo_html: '',
|
||||
button_text: 'Upgrade to Pro',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
function TriggerFormDialog({
|
||||
trigger,
|
||||
triggerKeys,
|
||||
existingTriggerKeys,
|
||||
onSave,
|
||||
onClose,
|
||||
isOpen,
|
||||
}: {
|
||||
trigger?: UpgradeTrigger;
|
||||
triggerKeys: TriggerKeyOption[];
|
||||
existingTriggerKeys: string[];
|
||||
onSave: (data: CreateUpgradeTriggerRequest | UpdateUpgradeTriggerRequest, id?: number) => void;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
}) {
|
||||
const [formData, setFormData] = useState<TriggerFormData>(emptyFormData);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// Sync form data when trigger changes or dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (trigger) {
|
||||
setFormData({
|
||||
trigger_key: trigger.trigger_key || '',
|
||||
title: trigger.title || '',
|
||||
message: trigger.message || '',
|
||||
promo_html: trigger.promo_html || '',
|
||||
button_text: trigger.button_text || 'Upgrade to Pro',
|
||||
is_active: trigger.is_active ?? true,
|
||||
});
|
||||
} else {
|
||||
setFormData(emptyFormData);
|
||||
}
|
||||
setShowPreview(false);
|
||||
}
|
||||
}, [trigger, isOpen]);
|
||||
|
||||
// For editing: always include the current trigger's key in available options
|
||||
// For creating: filter out already used keys
|
||||
const availableKeys = trigger
|
||||
? triggerKeys // When editing, show all keys (user can change to any unused key)
|
||||
: triggerKeys.filter(k => !existingTriggerKeys.includes(k.key));
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.trigger_key || !formData.title || !formData.message) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (trigger) {
|
||||
onSave(formData, trigger.id);
|
||||
} else {
|
||||
onSave(formData);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{trigger ? 'Edit' : 'Create'} Upgrade Trigger</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure the upgrade prompt that appears when users hit this trigger point.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="trigger_key">Trigger Point *</Label>
|
||||
<Select
|
||||
value={formData.trigger_key}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, trigger_key: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select trigger point" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectItem key={key.key} value={key.key}>
|
||||
{key.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="e.g., Unlock More Properties"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message">Message *</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, message: e.target.value }))}
|
||||
placeholder="e.g., Upgrade to Pro to add unlimited properties..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="promo_html">Promo HTML (Optional)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
>
|
||||
{showPreview ? <EyeOff className="h-4 w-4 mr-1" /> : <Eye className="h-4 w-4 mr-1" />}
|
||||
{showPreview ? 'Hide Preview' : 'Show Preview'}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="promo_html"
|
||||
value={formData.promo_html}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, promo_html: e.target.value }))}
|
||||
placeholder="<div>Rich HTML content for the upgrade prompt...</div>"
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use HTML to create rich promotional content. Supports basic HTML tags.
|
||||
</p>
|
||||
{showPreview && formData.promo_html && (
|
||||
<Card className="mt-2">
|
||||
<CardHeader className="py-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
HTML Preview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="py-2">
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: formData.promo_html }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="button_text">Button Text</Label>
|
||||
<Input
|
||||
id="button_text"
|
||||
value={formData.button_text}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, button_text: e.target.value }))}
|
||||
placeholder="Upgrade to Pro"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">Active</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only active triggers will be shown to users
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, is_active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{trigger ? 'Save Changes' : 'Create Trigger'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UpgradeTriggersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [editingTrigger, setEditingTrigger] = useState<UpgradeTrigger | null>(null);
|
||||
|
||||
const { data: triggers, isLoading: triggersLoading } = useQuery({
|
||||
queryKey: ['upgrade-triggers'],
|
||||
queryFn: limitationsApi.listUpgradeTriggers,
|
||||
});
|
||||
|
||||
const { data: triggerKeys, isLoading: keysLoading } = useQuery({
|
||||
queryKey: ['trigger-keys'],
|
||||
queryFn: limitationsApi.getAvailableTriggerKeys,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: limitationsApi.createUpgradeTrigger,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
|
||||
toast.success('Upgrade trigger created');
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
|
||||
const message = error.response?.data?.error || error.message || 'Failed to create upgrade trigger';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateUpgradeTriggerRequest }) =>
|
||||
limitationsApi.updateUpgradeTrigger(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
|
||||
toast.success('Upgrade trigger updated');
|
||||
setEditingTrigger(null);
|
||||
},
|
||||
onError: (error: Error & { response?: { data?: { error?: string } } }) => {
|
||||
const message = error.response?.data?.error || error.message || 'Failed to update upgrade trigger';
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: limitationsApi.deleteUpgradeTrigger,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
|
||||
toast.success('Upgrade trigger deleted');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete upgrade trigger');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: number; is_active: boolean }) =>
|
||||
limitationsApi.updateUpgradeTrigger(id, { is_active }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-triggers'] });
|
||||
toast.success('Trigger status updated');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update trigger status');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = (data: CreateUpgradeTriggerRequest | UpdateUpgradeTriggerRequest, id?: number) => {
|
||||
if (id) {
|
||||
updateMutation.mutate({ id, data });
|
||||
} else {
|
||||
createMutation.mutate(data as CreateUpgradeTriggerRequest);
|
||||
}
|
||||
};
|
||||
|
||||
const existingTriggerKeys = triggers?.map(t => t.trigger_key) ?? [];
|
||||
|
||||
const getTriggerLabel = (key: string): string => {
|
||||
return triggerKeys?.find(k => k.key === key)?.label ?? key;
|
||||
};
|
||||
|
||||
if (triggersLoading || keysLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Upgrade Triggers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure upgrade prompts shown to free tier users at specific trigger points
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
Configured Triggers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These prompts appear when free tier users try to access Pro features
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{triggers && triggers.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Trigger Point</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Has HTML</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{triggers.map((trigger) => (
|
||||
<TableRow key={trigger.id}>
|
||||
<TableCell className="font-medium">
|
||||
{getTriggerLabel(trigger.trigger_key)}
|
||||
</TableCell>
|
||||
<TableCell>{trigger.title}</TableCell>
|
||||
<TableCell>
|
||||
{trigger.promo_html ? (
|
||||
<Badge variant="secondary">
|
||||
<Code className="h-3 w-3 mr-1" />
|
||||
Yes
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={trigger.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleActiveMutation.mutate({ id: trigger.id, is_active: checked })
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingTrigger(trigger)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Trigger?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete the "{trigger.title}" upgrade trigger.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate(trigger.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No upgrade triggers configured yet.</p>
|
||||
<p className="text-sm">Click "Add Trigger" to create your first one.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground space-y-2">
|
||||
<p><strong>Available Trigger Points:</strong></p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2">
|
||||
{triggerKeys?.map((key) => (
|
||||
<li key={key.key}>
|
||||
<strong>{key.label}</strong> ({key.key})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
{isCreateOpen && triggerKeys && triggerKeys.length > 0 && (
|
||||
<TriggerFormDialog
|
||||
key="create-new"
|
||||
triggerKeys={triggerKeys}
|
||||
existingTriggerKeys={existingTriggerKeys}
|
||||
onSave={handleSave}
|
||||
onClose={() => setIsCreateOpen(false)}
|
||||
isOpen={isCreateOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{editingTrigger && triggerKeys && triggerKeys.length > 0 && (
|
||||
<TriggerFormDialog
|
||||
key={editingTrigger.id}
|
||||
trigger={editingTrigger}
|
||||
triggerKeys={triggerKeys}
|
||||
existingTriggerKeys={existingTriggerKeys}
|
||||
onSave={handleSave}
|
||||
onClose={() => setEditingTrigger(null)}
|
||||
isOpen={!!editingTrigger}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, TestTube, Shield } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Database, TestTube } from 'lucide-react';
|
||||
|
||||
import { settingsApi } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -28,24 +26,6 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: settingsApi.get,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: settingsApi.update,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
toast.success('Settings updated');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update settings');
|
||||
},
|
||||
});
|
||||
|
||||
const seedLookupsMutation = useMutation({
|
||||
mutationFn: settingsApi.seedLookups,
|
||||
onSuccess: (data) => {
|
||||
@@ -66,26 +46,6 @@ export default function SettingsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleLimitationsToggle = () => {
|
||||
if (settings) {
|
||||
updateMutation.mutate({
|
||||
enable_limitations: !settings.enable_limitations,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
<div className="h-32 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
@@ -96,35 +56,6 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Subscription Limitations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Subscription Limitations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control whether tier-based limitations are enforced for users
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="enable-limitations">Enable Limitations</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, free tier users will have restricted access to features
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="enable-limitations"
|
||||
checked={settings?.enable_limitations ?? false}
|
||||
onCheckedChange={handleLimitationsToggle}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Seed Lookup Data */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
CheckCircle,
|
||||
BookOpen,
|
||||
UserCog,
|
||||
Shield,
|
||||
Layers,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useAuthStore } from '@/store/auth';
|
||||
@@ -47,6 +50,11 @@ const menuItems = [
|
||||
{ title: 'Subscriptions', url: '/admin/subscriptions', icon: CreditCard },
|
||||
];
|
||||
|
||||
const limitationsItems = [
|
||||
{ title: 'Tier Limits', url: '/admin/limitations', icon: Layers },
|
||||
{ title: 'Upgrade Triggers', url: '/admin/limitations/triggers', icon: Sparkles },
|
||||
];
|
||||
|
||||
const settingsItems = [
|
||||
{ title: 'Lookup Tables', url: '/admin/lookups', icon: BookOpen },
|
||||
{ title: 'Admin Users', url: '/admin/admin-users', icon: UserCog },
|
||||
@@ -94,6 +102,27 @@ export function AppSidebar() {
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Limitations</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{limitationsItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === item.url || (item.url !== '/admin/limitations' && pathname.startsWith(item.url))}
|
||||
>
|
||||
<a href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
||||
+127
-1
@@ -392,6 +392,13 @@ export const authTokensApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Task Completion Image
|
||||
export interface TaskCompletionImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
// Task Completions Types
|
||||
export interface TaskCompletion {
|
||||
id: number;
|
||||
@@ -404,7 +411,8 @@ export interface TaskCompletion {
|
||||
completed_at: string;
|
||||
notes: string;
|
||||
actual_cost: string | null;
|
||||
photo_url: string;
|
||||
rating: number | null;
|
||||
images: TaskCompletionImage[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -664,4 +672,122 @@ export const settingsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// Limitations types
|
||||
export interface LimitationsSettings {
|
||||
enable_limitations: boolean;
|
||||
}
|
||||
|
||||
export interface TierLimits {
|
||||
id: number;
|
||||
tier: 'free' | 'pro';
|
||||
properties_limit: number | null;
|
||||
tasks_limit: number | null;
|
||||
contractors_limit: number | null;
|
||||
documents_limit: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UpdateTierLimitsRequest {
|
||||
properties_limit: number | null;
|
||||
tasks_limit: number | null;
|
||||
contractors_limit: number | null;
|
||||
documents_limit: number | null;
|
||||
}
|
||||
|
||||
export interface UpgradeTrigger {
|
||||
id: number;
|
||||
trigger_key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
promo_html: string;
|
||||
button_text: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TriggerKeyOption {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CreateUpgradeTriggerRequest {
|
||||
trigger_key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
promo_html?: string;
|
||||
button_text?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUpgradeTriggerRequest {
|
||||
trigger_key?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
promo_html?: string;
|
||||
button_text?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
// Limitations API
|
||||
export const limitationsApi = {
|
||||
// Settings
|
||||
getSettings: async (): Promise<LimitationsSettings> => {
|
||||
const response = await api.get<LimitationsSettings>('/limitations/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateSettings: async (data: { enable_limitations: boolean }): Promise<LimitationsSettings> => {
|
||||
const response = await api.put<LimitationsSettings>('/limitations/settings', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Tier Limits
|
||||
listTierLimits: async (): Promise<TierLimits[]> => {
|
||||
const response = await api.get<{ data: TierLimits[]; total: number }>('/limitations/tier-limits');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getTierLimits: async (tier: 'free' | 'pro'): Promise<TierLimits> => {
|
||||
const response = await api.get<TierLimits>(`/limitations/tier-limits/${tier}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTierLimits: async (tier: 'free' | 'pro', data: UpdateTierLimitsRequest): Promise<TierLimits> => {
|
||||
const response = await api.put<TierLimits>(`/limitations/tier-limits/${tier}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upgrade Triggers
|
||||
getAvailableTriggerKeys: async (): Promise<TriggerKeyOption[]> => {
|
||||
const response = await api.get<TriggerKeyOption[]>('/limitations/upgrade-triggers/keys');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listUpgradeTriggers: async (): Promise<UpgradeTrigger[]> => {
|
||||
const response = await api.get<{ data: UpgradeTrigger[]; total: number }>('/limitations/upgrade-triggers');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getUpgradeTrigger: async (id: number): Promise<UpgradeTrigger> => {
|
||||
const response = await api.get<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createUpgradeTrigger: async (data: CreateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
|
||||
const response = await api.post<UpgradeTrigger>('/limitations/upgrade-triggers', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateUpgradeTrigger: async (id: number, data: UpdateUpgradeTriggerRequest): Promise<UpgradeTrigger> => {
|
||||
const response = await api.put<UpgradeTrigger>(`/limitations/upgrade-triggers/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteUpgradeTrigger: async (id: number): Promise<void> => {
|
||||
await api.delete(`/limitations/upgrade-triggers/${id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -265,6 +265,12 @@ export interface UpdateContractorRequest {
|
||||
}
|
||||
|
||||
// Document types
|
||||
export interface DocumentImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
residence_id: number;
|
||||
@@ -278,6 +284,7 @@ export interface Document {
|
||||
expiry_date?: string;
|
||||
purchase_date?: string;
|
||||
is_active: boolean;
|
||||
images: DocumentImage[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -308,6 +315,7 @@ export interface CreateDocumentRequest {
|
||||
serial_number?: string;
|
||||
model_number?: string;
|
||||
task_id?: number;
|
||||
image_urls?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateDocumentRequest {
|
||||
|
||||
Reference in New Issue
Block a user