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:
Trey t
2025-11-28 11:07:51 -06:00
parent 3cd222c048
commit 5e95dcd015
31 changed files with 2595 additions and 320 deletions
@@ -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>
+27 -18
View File
@@ -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 &quot;{trigger.title}&quot; 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 &quot;Add Trigger&quot; 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>
);
}
+2 -71
View File
@@ -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>
+29
View File
@@ -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
View File
@@ -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;
+8
View File
@@ -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 {