Bug/template and sandbox (#589)
* Enhance booking template functionality and add sandbox extraction checks * Implement Recapt integration for feedback submission and user identification * Add Recapt identification component and bank sync status chip; update crontab entries * Update .gitignore to ignore the entire scripts directory * Refactor Recapt integration: add loader component, update privacy policy, and enhance bank sync status messages * Fix .gitignore to correctly ignore the scripts directory
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hides Recapt's floating feedback bubble while keeping the SDK active so
|
||||
* `window.recapt('identify', ...)` and programmatic `window.recapt('feedback',
|
||||
* { message })` calls continue to work. Mounted globally in the root layout.
|
||||
*/
|
||||
export function RecaptHideWidget() {
|
||||
useEffect(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
|
||||
const hide = (): boolean => {
|
||||
if (typeof window.recapt !== 'function') return false
|
||||
try {
|
||||
window.recapt('feedback', { widget: 'hide' })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (hide()) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
attempts++
|
||||
if (hide() || attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function RecaptIdentify({
|
||||
userId,
|
||||
email,
|
||||
displayName,
|
||||
}: {
|
||||
userId: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
}) {
|
||||
useEffect(() => {
|
||||
let attempts = 0
|
||||
const maxAttempts = 50
|
||||
const interval = setInterval(() => {
|
||||
if (typeof window.recapt === 'function') {
|
||||
window.recapt('identify', {
|
||||
uid: userId,
|
||||
email,
|
||||
nickname: displayName,
|
||||
})
|
||||
clearInterval(interval)
|
||||
return
|
||||
}
|
||||
attempts++
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [userId, email, displayName])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
|
||||
/**
|
||||
* Loads the Recapt SDK for authenticated dashboard users only.
|
||||
*
|
||||
* Privacy guard rails:
|
||||
* - Mounted inside the dashboard layout, so the script never loads on
|
||||
* public pages (login, register, privacy policy, marketing). This
|
||||
* prevents pre-consent IP/fingerprint collection on those routes.
|
||||
* - The public key is sourced from NEXT_PUBLIC_RECAPT_PUBLIC_KEY so
|
||||
* hosted and self-hosted deployments can each supply their own key
|
||||
* (or disable Recapt entirely by leaving it unset).
|
||||
* - data-persist / data-enable-user-comments are intentionally omitted
|
||||
* from the default tag. Persistent cross-session tracking and
|
||||
* unstructured free-text capture are opt-in product decisions, not
|
||||
* defaults (GDPR Art. 25 — privacy by default).
|
||||
*/
|
||||
export function RecaptLoader() {
|
||||
const publicKey = process.env.NEXT_PUBLIC_RECAPT_PUBLIC_KEY
|
||||
if (!publicKey) return null
|
||||
|
||||
return (
|
||||
<Script
|
||||
src="https://cdn.recapt.app/browser/glimt.js"
|
||||
strategy="afterInteractive"
|
||||
data-public-key={publicKey}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { resolveIcon } from '@/lib/extensions/icon-resolver'
|
||||
import { clearRecaptIdentity } from '@/lib/recapt'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -212,6 +213,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
clearRecaptIdentity()
|
||||
await supabase.auth.signOut()
|
||||
router.push(isSandbox ? '/sandbox' : '/login')
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe } from 'lucide-react'
|
||||
import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
|
||||
import { TEMPLATE_CATEGORY_LABELS, convertLibraryToBookingTemplate } from '@/lib/bookkeeping/template-library'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
|
||||
|
||||
@@ -264,38 +264,43 @@ function TemplateSection({
|
||||
<div className="space-y-1">
|
||||
{templates.map((tt) => {
|
||||
const isExpanded = expandedId === tt.id
|
||||
const isConvertible = convertLibraryToBookingTemplate(tt) !== null
|
||||
return (
|
||||
<div
|
||||
key={tt.id}
|
||||
className="rounded-lg border"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
</Badge>
|
||||
{tt.entity_type !== 'all' && (
|
||||
<div className="flex items-center gap-3 p-3 hover:bg-muted/50 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(isExpanded ? null : tt.id)}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 text-left"
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium">{tt.name}</span>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{entityLabels[tt.entity_type]}
|
||||
{TEMPLATE_CATEGORY_LABELS[tt.category]}
|
||||
</Badge>
|
||||
)}
|
||||
{tt.entity_type !== 'all' && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
|
||||
{entityLabels[tt.entity_type]}
|
||||
</Badge>
|
||||
)}
|
||||
{!isConvertible && (
|
||||
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
|
||||
{t('unconvertible_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(tt.id)
|
||||
}}
|
||||
onClick={() => onDelete(tt.id)}
|
||||
disabled={deletingId === tt.id}
|
||||
className="h-8 w-8 p-0 shrink-0"
|
||||
>
|
||||
@@ -306,7 +311,7 @@ function TemplateSection({
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 pt-0">
|
||||
{tt.description && (
|
||||
@@ -369,8 +374,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
})
|
||||
}
|
||||
|
||||
function updateLineType(index: number, newType: BookingTemplateLibraryLine['type']) {
|
||||
setLines((prev) => {
|
||||
const updated = [...prev]
|
||||
const current = updated[index]
|
||||
const next: BookingTemplateLibraryLine = { ...current, type: newType }
|
||||
// Auto-pick a sensible default for the type-specific field so the
|
||||
// converter (and applyTemplate) sees a complete line shape.
|
||||
if (newType === 'vat' && next.vat_rate === undefined) {
|
||||
next.vat_rate = 0.25
|
||||
}
|
||||
updated[index] = next
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
// Default new lines to a VAT line — the 2-line template starts with one
|
||||
// business + one settlement, and the natural extension is a VAT leg.
|
||||
// Defaulting to 'business' instead would silently break the converter
|
||||
// (which requires exactly one business line) and the template would
|
||||
// disappear from the transaction picker.
|
||||
function addLine() {
|
||||
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }])
|
||||
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'vat', vat_rate: 0.25 }])
|
||||
}
|
||||
|
||||
function removeLine(index: number) {
|
||||
@@ -378,6 +403,28 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
setLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Real-time check: can this draft be picked from the transaction sheet?
|
||||
// If not, we show a hint — save remains allowed (templates may still be
|
||||
// useful from the journal-entry form).
|
||||
const isConvertible = (() => {
|
||||
const draft: BookingTemplateLibrary = {
|
||||
id: '',
|
||||
company_id: null,
|
||||
team_id: null,
|
||||
created_by: null,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
entity_type: entityType,
|
||||
lines,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
return convertLibraryToBookingTemplate(draft) !== null
|
||||
})()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name || lines.some((l) => !l.account || !l.label)) {
|
||||
@@ -464,7 +511,7 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<SelectItem value="credit">{t('credit_label')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
|
||||
<Select value={line.type} onValueChange={(v) => updateLineType(i, v as BookingTemplateLibraryLine['type'])}>
|
||||
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="business">{t('type_cost')}</SelectItem>
|
||||
@@ -472,6 +519,20 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
<SelectItem value="settlement">{t('type_settlement')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{line.type === 'vat' && (
|
||||
<Select
|
||||
value={String(line.vat_rate ?? 0.25)}
|
||||
onValueChange={(v) => updateLine(i, 'vat_rate', Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-20" aria-label={t('vat_rate_label')}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25">{t('vat_rate_25')}</SelectItem>
|
||||
<SelectItem value="0.12">{t('vat_rate_12')}</SelectItem>
|
||||
<SelectItem value="0.06">{t('vat_rate_6')}</SelectItem>
|
||||
<SelectItem value="0">{t('vat_rate_0')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -491,6 +552,14 @@ function CreateTemplateForm({ onCreated, entityLabels }: { onCreated: () => void
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isConvertible && (
|
||||
<div className="rounded-lg border border-warning/30 bg-warning/[0.03] px-3 py-2">
|
||||
<p className="text-xs text-warning-foreground leading-snug">
|
||||
{t('unconvertible_hint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting} className="w-full">
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{t('create_button')}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
|
||||
interface ConnectionRow {
|
||||
id: string
|
||||
status: string | null
|
||||
last_synced_at: string | null
|
||||
}
|
||||
|
||||
function useAgeFormatter() {
|
||||
const t = useTranslations('transactions')
|
||||
return (iso: string): string => {
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const min = Math.floor(ms / 60000)
|
||||
if (min < 1) return t('bank_sync_age_just_now')
|
||||
if (min < 60) return t('bank_sync_age_minutes', { count: min })
|
||||
const h = Math.floor(min / 60)
|
||||
if (h < 24) return t('bank_sync_age_hours', { count: h })
|
||||
const d = Math.floor(h / 24)
|
||||
return t('bank_sync_age_days', { count: d })
|
||||
}
|
||||
}
|
||||
|
||||
export default function BankSyncStatusChip() {
|
||||
const t = useTranslations('transactions')
|
||||
const formatAge = useAgeFormatter()
|
||||
const { company } = useCompany()
|
||||
const [rows, setRows] = useState<ConnectionRow[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
let cancelled = false
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, status, last_synced_at')
|
||||
.eq('company_id', company.id)
|
||||
.then(({ data }) => {
|
||||
if (!cancelled) setRows(data ?? [])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [company?.id])
|
||||
|
||||
if (!rows || rows.length === 0) return null
|
||||
|
||||
const needsAttention = rows.filter(
|
||||
(r) => r.status === 'expired' || r.status === 'error',
|
||||
)
|
||||
|
||||
if (needsAttention.length > 0) {
|
||||
return (
|
||||
<Link
|
||||
href="/settings/banking"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/40 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{needsAttention.length === 1
|
||||
? t('bank_sync_attention_one')
|
||||
: t('bank_sync_attention_many', { count: needsAttention.length })}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const mostRecent = rows
|
||||
.map((r) => r.last_synced_at)
|
||||
.filter((s): s is string => Boolean(s))
|
||||
.sort()
|
||||
.pop()
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1 text-xs text-muted-foreground">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{t('bank_sync_auto_nightly')}
|
||||
{mostRecent && (
|
||||
<>
|
||||
{t('bank_sync_last_separator')}
|
||||
<span className="tabular-nums">{formatAge(mostRecent)}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user