feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
@@ -1,24 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { updateBillingConfig, updatePlanEntitlement } from '@/app/actions/admin-billing'
|
||||
import { updateBillingConfig } from '@/app/actions/admin-billing'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { CreditCard, Gauge, Settings2 } from 'lucide-react'
|
||||
|
||||
type EntitlementRow = {
|
||||
tier: string
|
||||
feature: string
|
||||
limitValue: number | null
|
||||
mode: 'limited' | 'unlimited' | 'unavailable'
|
||||
}
|
||||
import { CreditCard, Gauge, Coins, Package } from 'lucide-react'
|
||||
|
||||
type BillingAdminData = {
|
||||
entitlements: EntitlementRow[]
|
||||
billingConfig: Record<string, string>
|
||||
usageOverview: {
|
||||
period: string
|
||||
@@ -26,34 +18,55 @@ type BillingAdminData = {
|
||||
byFeature: Array<{ feature: string; requests: number; tokens: number; users: number }>
|
||||
topUsers: Array<{ userId: string; email: string; name: string | null; requests: number }>
|
||||
}
|
||||
features: string[]
|
||||
tiers: string[]
|
||||
/** Allocations mensuelles du solde unique (source de vérité débit) */
|
||||
creditAllocations?: Array<{
|
||||
tier: string
|
||||
monthlyCredits: number | null
|
||||
unlimited: boolean
|
||||
}>
|
||||
creditCosts?: Record<string, number>
|
||||
creditPacks?: Array<{
|
||||
id: string
|
||||
credits: number
|
||||
defaultDisplay: string
|
||||
}>
|
||||
}
|
||||
|
||||
function getEntitlement(
|
||||
entitlements: EntitlementRow[],
|
||||
tier: string,
|
||||
feature: string,
|
||||
): EntitlementRow | undefined {
|
||||
return entitlements.find((e) => e.tier === tier && e.feature === feature)
|
||||
const SUBSCRIPTION_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_PRO_MONTHLY',
|
||||
'STRIPE_PRICE_PRO_ANNUAL',
|
||||
'STRIPE_PRICE_BUSINESS_MONTHLY',
|
||||
'STRIPE_PRICE_BUSINESS_ANNUAL',
|
||||
] as const
|
||||
|
||||
const PACK_PRICE_KEYS = [
|
||||
'STRIPE_PRICE_CREDITS_S',
|
||||
'STRIPE_PRICE_CREDITS_M',
|
||||
'STRIPE_PRICE_CREDITS_L',
|
||||
] as const
|
||||
|
||||
/** Date stable SSR/client (pas de toLocaleString — mismatch locale + fuseau). */
|
||||
function formatStableUtc(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
||||
}
|
||||
|
||||
export function BillingAdminClient({ initialData }: { initialData: BillingAdminData }) {
|
||||
const { t } = useLanguage()
|
||||
const [activeTier, setActiveTier] = useState(initialData.tiers[0] ?? 'BASIC')
|
||||
const [billingEnabled, setBillingEnabled] = useState(initialData.billingConfig.BILLING_ENABLED === 'true')
|
||||
const [isSavingBilling, setIsSavingBilling] = useState(false)
|
||||
const [savingCell, setSavingCell] = useState<string | null>(null)
|
||||
|
||||
const handleSaveBilling = async (formData: FormData) => {
|
||||
setIsSavingBilling(true)
|
||||
try {
|
||||
const data: Record<string, string> = {
|
||||
BILLING_ENABLED: billingEnabled ? 'true' : 'false',
|
||||
STRIPE_PRICE_PRO_MONTHLY: String(formData.get('STRIPE_PRICE_PRO_MONTHLY') ?? ''),
|
||||
STRIPE_PRICE_PRO_ANNUAL: String(formData.get('STRIPE_PRICE_PRO_ANNUAL') ?? ''),
|
||||
STRIPE_PRICE_BUSINESS_MONTHLY: String(formData.get('STRIPE_PRICE_BUSINESS_MONTHLY') ?? ''),
|
||||
STRIPE_PRICE_BUSINESS_ANNUAL: String(formData.get('STRIPE_PRICE_BUSINESS_ANNUAL') ?? ''),
|
||||
}
|
||||
for (const key of [...SUBSCRIPTION_PRICE_KEYS, ...PACK_PRICE_KEYS]) {
|
||||
data[key] = String(formData.get(key) ?? '')
|
||||
}
|
||||
await updateBillingConfig(data)
|
||||
toast.success(t('admin.billing.configSaved'))
|
||||
@@ -64,23 +77,6 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
}
|
||||
}
|
||||
|
||||
const handleEntitlementChange = async (
|
||||
feature: string,
|
||||
mode: 'unavailable' | 'unlimited' | 'limited',
|
||||
limitValue?: number,
|
||||
) => {
|
||||
const key = `${activeTier}:${feature}`
|
||||
setSavingCell(key)
|
||||
try {
|
||||
await updatePlanEntitlement(activeTier, feature, mode, limitValue)
|
||||
toast.success(t('admin.billing.limitSaved'))
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : t('admin.billing.limitFailed'))
|
||||
} finally {
|
||||
setSavingCell(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -98,7 +94,7 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.stripeConfigDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveBilling(new FormData(e.currentTarget)) }} className="p-6 space-y-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="BILLING_ENABLED"
|
||||
@@ -107,81 +103,105 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
/>
|
||||
<Label htmlFor="BILLING_ENABLED">{t('admin.billing.enableBilling')}</Label>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{(['STRIPE_PRICE_PRO_MONTHLY', 'STRIPE_PRICE_PRO_ANNUAL', 'STRIPE_PRICE_BUSINESS_MONTHLY', 'STRIPE_PRICE_BUSINESS_ANNUAL'] as const).map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">{t('admin.billing.subscriptionPricesTitle')}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{SUBSCRIPTION_PRICE_KEYS.map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/50 pt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">{t('admin.billing.packPricesTitle')}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">{t('admin.billing.packPricesDescription')}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{PACK_PRICE_KEYS.map((key) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>{t(`admin.billing.${key}`)}</Label>
|
||||
<Input
|
||||
id={key}
|
||||
name={key}
|
||||
defaultValue={initialData.billingConfig[key] ?? ''}
|
||||
placeholder="price_..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.billing.secretsNote')}</p>
|
||||
<Button type="submit" disabled={isSavingBilling}>{t('admin.billing.saveConfig')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Solde unique — source de vérité du débit */}
|
||||
<div className="bg-card rounded-lg border border-border shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-6 border-b border-border">
|
||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||
<Settings2 className="h-5 w-5" />
|
||||
<Coins className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{t('admin.billing.limitsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.limitsDescription')}</p>
|
||||
<h2 className="font-semibold">{t('admin.billing.creditsTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.billing.creditsDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{initialData.tiers.map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
type="button"
|
||||
onClick={() => setActiveTier(tier)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
|
||||
activeTier === tier
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{(initialData.creditAllocations ?? []).map((row) => (
|
||||
<div
|
||||
key={row.tier}
|
||||
className="rounded-xl border border-border/60 bg-muted/30 p-4 space-y-1"
|
||||
>
|
||||
{tier}
|
||||
</button>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{row.tier}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{row.unlimited
|
||||
? t('admin.billing.modeUnlimited')
|
||||
: row.monthlyCredits?.toLocaleString('fr-FR')}
|
||||
</p>
|
||||
{!row.unlimited && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t('admin.billing.creditsPerMonth')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2 pr-4">{t('admin.billing.feature')}</th>
|
||||
<th className="py-2 pr-4">{t('admin.billing.mode')}</th>
|
||||
<th className="py-2 pr-4">{t('admin.billing.monthlyLimit')}</th>
|
||||
<th className="py-2">{t('admin.billing.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialData.features.map((feature) => {
|
||||
const row = getEntitlement(initialData.entitlements, activeTier, feature)
|
||||
const mode = row?.mode ?? 'unavailable'
|
||||
const cellKey = `${activeTier}:${feature}`
|
||||
return (
|
||||
<EntitlementRowEditor
|
||||
key={`${activeTier}:${feature}`}
|
||||
feature={feature}
|
||||
mode={mode}
|
||||
limitValue={row?.limitValue ?? null}
|
||||
isSaving={savingCell === cellKey}
|
||||
onSave={handleEntitlementChange}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(initialData.creditPacks?.length ?? 0) > 0 && (
|
||||
<div className="border-t border-border/40 pt-3">
|
||||
<p className="text-xs font-medium mb-2">{t('admin.billing.packsCatalogTitle')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(initialData.creditPacks ?? []).map((pack) => (
|
||||
<span
|
||||
key={pack.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border/60 bg-muted/20 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<span className="font-semibold uppercase">{pack.id}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{pack.credits.toLocaleString('fr-FR')} cr. · {pack.defaultDisplay}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground leading-relaxed border-t border-border/40 pt-3">
|
||||
{t('admin.billing.creditsCostsNote')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.billing.creditsResetHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +215,10 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('admin.billing.usagePeriod', { period: initialData.usageOverview.period })}
|
||||
{initialData.usageOverview.lastSyncedAt
|
||||
? ` · ${t('admin.billing.lastSync', { date: new Date(initialData.usageOverview.lastSyncedAt).toLocaleString() })}`
|
||||
? ` · ${t('admin.billing.lastSync', {
|
||||
// Format fixe UTC (évite mismatch SSR locale/fuseau vs client)
|
||||
date: formatStableUtc(initialData.usageOverview.lastSyncedAt),
|
||||
})}`
|
||||
: ` · ${t('admin.billing.notSynced')}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -236,68 +259,3 @@ export function BillingAdminClient({ initialData }: { initialData: BillingAdminD
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EntitlementRowEditor({
|
||||
feature,
|
||||
mode: initialMode,
|
||||
limitValue: initialLimit,
|
||||
isSaving,
|
||||
onSave,
|
||||
t,
|
||||
}: {
|
||||
feature: string
|
||||
mode: 'unavailable' | 'unlimited' | 'limited'
|
||||
limitValue: number | null
|
||||
isSaving: boolean
|
||||
onSave: (feature: string, mode: 'unavailable' | 'unlimited' | 'limited', limit?: number) => Promise<void>
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}) {
|
||||
const [mode, setMode] = useState(initialMode)
|
||||
const [limit, setLimit] = useState(initialLimit != null ? String(initialLimit) : '50')
|
||||
|
||||
useEffect(() => {
|
||||
setMode(initialMode)
|
||||
setLimit(initialLimit != null ? String(initialLimit) : '50')
|
||||
}, [initialMode, initialLimit])
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/30">
|
||||
<td className="py-3 pr-4 font-mono text-xs">{feature}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as typeof mode)}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="unavailable">{t('admin.billing.modeUnavailable')}</option>
|
||||
<option value="limited">{t('admin.billing.modeLimited')}</option>
|
||||
<option value="unlimited">{t('admin.billing.modeUnlimited')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{mode === 'limited' ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
className="h-9 w-24 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isSaving}
|
||||
onClick={() => onSave(feature, mode, mode === 'limited' ? parseInt(limit, 10) : undefined)}
|
||||
>
|
||||
{isSaving ? '…' : t('admin.billing.saveLimit')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,11 +139,11 @@ const SUGGESTED_EMBEDDINGS: Record<string, string[]> = {
|
||||
zai: ['text-embedding-3-small', 'text-embedding-3-large'],
|
||||
}
|
||||
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat'
|
||||
type ModelPurpose = 'tags' | 'embeddings' | 'chat' | 'slides'
|
||||
|
||||
export function AdminSettingsForm({ config }: { config: Record<string, string> }) {
|
||||
const { t } = useLanguage()
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat'>('tags')
|
||||
const [activeAiTab, setActiveAiTab] = useState<'tags' | 'embeddings' | 'chat' | 'slides'>('tags')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
|
||||
@@ -168,11 +168,15 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
return PROVIDERS_WITHOUT_EMBEDDINGS.includes(v) ? 'ollama' : v
|
||||
})
|
||||
const [chatProvider, setChatProvider] = useState<AIProvider>((config.AI_PROVIDER_CHAT as AIProvider) || 'ollama')
|
||||
const [slidesProvider, setSlidesProvider] = useState<AIProvider>(
|
||||
(config.AI_PROVIDER_SLIDES as AIProvider) || (config.AI_PROVIDER_CHAT as AIProvider) || 'ollama',
|
||||
)
|
||||
|
||||
// Selected Models State
|
||||
const [selectedTagsModel, setSelectedTagsModel] = useState<string>(config.AI_MODEL_TAGS || '')
|
||||
const [selectedEmbeddingModel, setSelectedEmbeddingModel] = useState<string>(config.AI_MODEL_EMBEDDING || '')
|
||||
const [selectedChatModel, setSelectedChatModel] = useState<string>(config.AI_MODEL_CHAT || '')
|
||||
const [selectedSlidesModel, setSelectedSlidesModel] = useState<string>(config.AI_MODEL_SLIDES || '')
|
||||
|
||||
const [tagsFallbackProvider, setTagsFallbackProvider] = useState<string>(config.AI_PROVIDER_TAGS_FALLBACK || '')
|
||||
const [embedFallbackProvider, setEmbedFallbackProvider] = useState<string>(config.AI_PROVIDER_EMBEDDING_FALLBACK || '')
|
||||
@@ -186,11 +190,13 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
tags: [],
|
||||
embeddings: [],
|
||||
chat: [],
|
||||
slides: [],
|
||||
})
|
||||
const [isLoadingModels, setIsLoadingModels] = useState<Record<ModelPurpose, boolean>>({
|
||||
tags: false,
|
||||
embeddings: false,
|
||||
chat: false,
|
||||
slides: false,
|
||||
})
|
||||
|
||||
// Fetch models from local providers (Ollama, LM Studio) or cloud providers with /v1/models
|
||||
@@ -268,6 +274,16 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
const key = config[API_KEY_CONFIG[chatProvider]] || ''
|
||||
if (url && key) await fetchModels('chat', chatProvider, url, key)
|
||||
}
|
||||
|
||||
if (slidesProvider === 'ollama') {
|
||||
await fetchModels('slides', 'ollama', config.OLLAMA_BASE_URL_CHAT || config.OLLAMA_BASE_URL || 'http://localhost:11434')
|
||||
} else if (slidesProvider === 'lmstudio') {
|
||||
await fetchModels('slides', 'lmstudio', config.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1')
|
||||
} else if (PROVIDER_META[slidesProvider]?.hasApiKey && slidesProvider !== 'anthropic_custom') {
|
||||
const url = DEFAULT_BASE_URLS[slidesProvider]
|
||||
const key = config[API_KEY_CONFIG[slidesProvider]] || ''
|
||||
if (url && key) await fetchModels('slides', slidesProvider, url, key)
|
||||
}
|
||||
}
|
||||
fetchInitial()
|
||||
|
||||
@@ -408,6 +424,32 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
}
|
||||
}
|
||||
|
||||
// Slides provider (dedicated model for deck generation — never auto-renamed)
|
||||
const slidesProv = formData.get('AI_PROVIDER_SLIDES') as string
|
||||
data.AI_PROVIDER_SLIDES = slidesProv?.trim() ?? ''
|
||||
const slidesModel = formData.get('AI_MODEL_SLIDES') as string
|
||||
data.AI_MODEL_SLIDES = slidesModel?.trim() ?? ''
|
||||
if (slidesProv) {
|
||||
if (slidesProv === 'ollama') {
|
||||
const ollamaUrl = formData.get('BASE_URL_ollama_slides') as string
|
||||
if (ollamaUrl) data.OLLAMA_BASE_URL_CHAT = ollamaUrl
|
||||
} else if (slidesProv === 'lmstudio') {
|
||||
const url = formData.get('BASE_URL_lmstudio_slides') as string
|
||||
if (url) data.LMSTUDIO_BASE_URL = url
|
||||
} else {
|
||||
const apiKeyConfigKey = API_KEY_CONFIG[slidesProv as AIProvider]
|
||||
if (apiKeyConfigKey) {
|
||||
const apiKey = formData.get(`API_KEY_${slidesProv}_slides`) as string
|
||||
if (apiKey) data[apiKeyConfigKey] = apiKey
|
||||
}
|
||||
const baseUrlConfigKey = BASE_URL_CONFIG[slidesProv as AIProvider]
|
||||
if (baseUrlConfigKey) {
|
||||
const baseUrl = formData.get(`BASE_URL_${slidesProv}_slides`) as string
|
||||
if (baseUrl) data[baseUrlConfigKey] = baseUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await updateSystemConfig(data)
|
||||
setIsSaving(false)
|
||||
|
||||
@@ -740,6 +782,7 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
<button type="button" onClick={() => setActiveAiTab('tags')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'tags' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🏷️ Tags</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('embeddings')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'embeddings' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>🔍 Embeddings</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('chat')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'chat' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>💬 Chat</button>
|
||||
<button type="button" onClick={() => setActiveAiTab('slides')} className={`px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap ${activeAiTab === 'slides' ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'}`}>📑 {t('admin.ai.slidesTab')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -945,6 +988,44 @@ export function AdminSettingsForm({ config }: { config: Record<string, string> }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slides Provider — dedicated model for deck generation */}
|
||||
<div className={`space-y-4 p-4 border border-border/50 rounded-lg bg-muted/50 ${activeAiTab === 'slides' ? 'block' : 'hidden'}`}>
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<span>📑</span> {t('admin.ai.slidesProvider')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.ai.slidesDescription')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="AI_PROVIDER_SLIDES">{t('admin.ai.provider')}</Label>
|
||||
<select
|
||||
id="AI_PROVIDER_SLIDES"
|
||||
name="AI_PROVIDER_SLIDES"
|
||||
value={slidesProvider}
|
||||
onChange={(e) => {
|
||||
setSlidesProvider(e.target.value as AIProvider)
|
||||
setSelectedSlidesModel('')
|
||||
setDynamicModels((prev) => ({ ...prev, slides: [] }))
|
||||
}}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{providerOptions.map((opt) => (
|
||||
<option key={`slides-${opt.value}`} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="AI_MODEL_SLIDES" value={selectedSlidesModel} />
|
||||
{renderProviderConfig(slidesProvider, 'slides', selectedSlidesModel, setSelectedSlidesModel)}
|
||||
|
||||
<p className="text-xs text-muted-foreground border-t border-border/40 pt-3">
|
||||
{t('admin.ai.slidesQuotaNote')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 pb-6 flex flex-col sm:flex-row gap-3 sm:justify-between pt-6">
|
||||
<Button type="submit" disabled={isSaving}>{isSaving ? t('admin.ai.saving') : t('admin.ai.saveSettings')}</Button>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteUser, updateUserRole, updateUserSubscription } from '@/app/actions/admin'
|
||||
import { deleteUser, updateUserRole, updateUserSubscription, resetUserQuotas } from '@/app/actions/admin'
|
||||
import { toast } from 'sonner'
|
||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown } from 'lucide-react'
|
||||
import { Trash2, Shield, ShieldOff, Crown, ChevronDown, RotateCcw } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
@@ -91,6 +91,7 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
const { t } = useLanguage()
|
||||
const [users, setUsers] = useState(initialUsers)
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||
const [resettingId, setResettingId] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t('admin.users.confirmDelete'))) return
|
||||
@@ -103,6 +104,27 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetQuotas = async (user: { id: string; email?: string | null; name?: string | null }) => {
|
||||
const label = user.email || user.name || user.id
|
||||
if (!confirm(t('admin.users.confirmResetQuotas', { user: label }))) return
|
||||
setResettingId(user.id)
|
||||
try {
|
||||
const result = await resetUserQuotas(user.id)
|
||||
toast.success(
|
||||
t('admin.users.resetQuotasSuccess', {
|
||||
period: result.period,
|
||||
count: result.featuresReset.length,
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t('admin.users.resetQuotasFailed'),
|
||||
)
|
||||
} finally {
|
||||
setResettingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = async (user: any) => {
|
||||
const newRole = user.role === 'ADMIN' ? 'USER' : 'ADMIN'
|
||||
try {
|
||||
@@ -163,6 +185,16 @@ export function UserList({ initialUsers }: { initialUsers: any[] }) {
|
||||
<td className="p-4 align-middle">{format(new Date(user.createdAt), 'PP')}</td>
|
||||
<td className="p-4 align-middle text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleResetQuotas(user)}
|
||||
disabled={resettingId === user.id}
|
||||
title={t('admin.users.resetQuotas')}
|
||||
className="text-amber-700 hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-200"
|
||||
>
|
||||
<RotateCcw className={`h-4 w-4 ${resettingId === user.id ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user