feat: add document bucket auto-creation, extension defaults, and chat widget on dashboard
Auto-create documents storage bucket on first upload. Default legacy general extensions to enabled when no toggle row exists. Add ChatWidget to dashboard root page with open-ai-chat event support and AI assistant quick action. Add ensureInitialized to supplier invoices route for event emission. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Legacy general extensions default to enabled when no toggle row exists
|
||||
const LEGACY_GENERAL_EXTENSIONS = [
|
||||
'receipt-ocr',
|
||||
'ai-categorization',
|
||||
'ai-chat',
|
||||
'push-notifications',
|
||||
'enable-banking',
|
||||
]
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ sector: string; slug: string }> }
|
||||
@@ -21,7 +30,14 @@ export async function GET(
|
||||
.eq('extension_slug', slug)
|
||||
.single()
|
||||
|
||||
return NextResponse.json({ data: data ?? { enabled: false } })
|
||||
if (data) {
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
// No toggle row: legacy general extensions default to enabled
|
||||
const defaultEnabled =
|
||||
sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug)
|
||||
return NextResponse.json({ data: { enabled: defaultEnabled } })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
|
||||
@@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockFindFiscalPeriod = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { redirect } from 'next/navigation'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import type { Deadline, ReceiptQueueSummary } from '@/types'
|
||||
import { ChatWidget } from '@/components/chat'
|
||||
|
||||
export default async function RootPage() {
|
||||
const supabase = await createClient()
|
||||
@@ -190,6 +191,7 @@ export default async function RootPage() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ChatWidget />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ export function ChatWidget() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
// Default to true for legacy compatibility (ai-chat defaults to enabled)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// Fetch initial toggle state
|
||||
useEffect(() => {
|
||||
@@ -22,8 +21,8 @@ export function ChatWidget() {
|
||||
// Legacy: enabled by default when no toggle row exists
|
||||
setEnabled(data?.enabled ?? true)
|
||||
}
|
||||
} finally {
|
||||
setLoaded(true)
|
||||
} catch {
|
||||
// Keep default (enabled) on fetch failure
|
||||
}
|
||||
}
|
||||
check()
|
||||
@@ -42,7 +41,14 @@ export function ChatWidget() {
|
||||
return () => window.removeEventListener('extension-toggle-changed', handler)
|
||||
}, [])
|
||||
|
||||
if (!loaded || !enabled) return null
|
||||
// Allow other components to open the chat via custom event
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOpen(true)
|
||||
window.addEventListener('open-ai-chat', handler)
|
||||
return () => window.removeEventListener('open-ai-chat', handler)
|
||||
}, [])
|
||||
|
||||
if (!enabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Landmark,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types'
|
||||
|
||||
@@ -179,6 +180,8 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS)
|
||||
const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS
|
||||
|
||||
const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat'))
|
||||
|
||||
// Quick action items
|
||||
const quickActions = [
|
||||
{ href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true },
|
||||
@@ -400,6 +403,18 @@ export default function DashboardContent({ firstName, settings, summary, onboard
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
{/* AI assistant quick action */}
|
||||
<button onClick={openAiChat} className="group text-left">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border/40 hover:bg-muted/30 transition-colors duration-150">
|
||||
<div className="p-2 rounded-lg bg-muted/50">
|
||||
<MessageCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">AI-assistent</p>
|
||||
<p className="text-xs text-muted-foreground truncate hidden md:block">Fråga om bokföring</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
storage: {
|
||||
getBucket: vi.fn().mockResolvedValue({ data: { id: 'documents' }, error: null }),
|
||||
createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }),
|
||||
from: vi.fn().mockReturnValue({
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
download: vi.fn().mockResolvedValue({
|
||||
@@ -43,14 +45,16 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
createServiceClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service'
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
_resetBucketVerified()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
// Reset the mock to use default makeClient
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
|
||||
|
||||
@@ -10,6 +10,33 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS
|
||||
* for documents linked to committed entries.
|
||||
*/
|
||||
|
||||
let bucketVerified = false
|
||||
|
||||
/** @internal Reset bucket verification flag — for testing only */
|
||||
export function _resetBucketVerified() {
|
||||
bucketVerified = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the 'documents' storage bucket exists, creating it if missing.
|
||||
* Runs once per process lifetime (same pattern as ensureInitialized).
|
||||
*/
|
||||
async function ensureDocumentsBucket(): Promise<void> {
|
||||
if (bucketVerified) return
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
const { data: bucket } = await supabase.storage.getBucket('documents')
|
||||
|
||||
if (!bucket) {
|
||||
await supabase.storage.createBucket('documents', {
|
||||
public: false,
|
||||
fileSizeLimit: 52428800, // 50 MB
|
||||
})
|
||||
}
|
||||
|
||||
bucketVerified = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file buffer
|
||||
*/
|
||||
@@ -31,6 +58,7 @@ export async function uploadDocument(
|
||||
journal_entry_line_id?: string
|
||||
} = {}
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
@@ -102,6 +130,7 @@ export async function createNewVersion(
|
||||
originalId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||||
): Promise<DocumentAttachment> {
|
||||
await ensureDocumentsBucket()
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
|
||||
Reference in New Issue
Block a user