diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index 2d52aa8e..e8531a4e 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -11,6 +11,11 @@ jobs: with: node-version: 20 - run: npm ci + - name: Verify skill bodies are in sync with the seed migration + # Fails if a .claude/skills/**/SKILL.md changed without regenerating the + # seed migration (npm run skills:generate). Keeps prod skill content from + # silently drifting out of sync. No DB needed — reads files + manifest. + run: npm run skills:check - name: Reset extensions config run: echo '{"extensions":[]}' > extensions.config.json - run: npm run setup:extensions diff --git a/.gitignore b/.gitignore index 68b4dcef..ce2e628f 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ supabase/.temp/ # The empty defaults in lib/extensions/_generated/ are committed so core compiles # out of the box without running the generator. supabase/.branches/ + +scripts\remap-krister-bas96-to-bas2025.ts diff --git a/CLAUDE.md b/CLAUDE.md index 7c60165d..dfed621a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,8 @@ npm run lint # ESLint npm test # Run all Vitest tests npx vitest run # Run tests in a specific directory npm run setup:extensions # Regenerate extension registry from extensions.config.json +npm run skills:generate # Regenerate agent_atom_registry seed migration from .claude/skills/**/SKILL.md (after editing a SKILL.md) +npm run skills:check # CI guard: fail if a SKILL.md changed without regenerating the seed migration ``` --- @@ -307,6 +309,8 @@ export async function POST(request: Request) { 7. Apply via Supabase MCP `apply_migration` 8. Always end with `NOTIFY pgrst, 'reload schema'` when altering table structure +**Agent skill bodies (`agent_atom_registry`)**: skill content is authored in `.claude/skills/**/SKILL.md` and inlined into the DB `body` column at runtime (not read from disk — that doesn't bundle on Vercel/Docker). After editing any SKILL.md, run `npm run skills:generate` to emit a new `*_seed_agent_atom_bodies.sql` migration and commit it; `npm run skills:check` (wired into CI) fails the build if you forget. The MCP server exposes only atoms with `mcp_exposed = true` (swarm-* audit skills are never atoms). + --- ## Skills, Git & CI diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 300f9fdb..02846d85 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -137,12 +137,14 @@ export default function BookkeepingPage() { +
+ +
} /> diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 681d5fbd..ad866f2e 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { ArrowLeft, Lock } from 'lucide-react' +import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { cn } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -200,14 +201,22 @@ export default function YearEndPage() { return (
-
+

Årsbokslut

- +
+ + +
{periods === null && !periodsError && ( diff --git a/app/(dashboard)/chat/[id]/page.tsx b/app/(dashboard)/chat/[id]/page.tsx new file mode 100644 index 00000000..0f391f69 --- /dev/null +++ b/app/(dashboard)/chat/[id]/page.tsx @@ -0,0 +1,65 @@ +import { createClient } from '@/lib/supabase/server' +import { notFound, redirect } from 'next/navigation' +import { getActiveCompanyId } from '@/lib/company/context' +import ChatConversationView from '@/components/agent/ChatConversationView' + +export const dynamic = 'force-dynamic' + +interface PageProps { + params: Promise<{ id: string }> +} + +// /chat/[id] — server-renders the conversation row + ordered messages, then +// hydrates the client AgentChat with them so the user can continue typing +// against the existing conversation_id. The agent loop on the server picks +// up via /api/agent/invoke with conversation_id supplied. +export default async function ChatConversationPage({ params }: PageProps) { + const { id } = await params + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) redirect('/onboarding') + + const { data: conversation } = await supabase + .from('agent_conversations') + .select('id, intent_id, context_ref, title, pinned, archived, last_message_at') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + + if (!conversation) notFound() + + const { data: messages } = await supabase + .from('agent_messages') + .select('role, content, hidden, created_at') + .eq('conversation_id', id) + .order('created_at', { ascending: true }) + + return ( + + ) +} + +function intentLabel(intentId: string): string { + switch (intentId) { + case 'general.help': + return 'Fråga din assistent' + case 'transaction.categorization': + return 'Hjälp med transaktion' + case 'invoice.draft': + return 'Hjälp med faktura' + case 'supplier_invoice.review': + return 'Granska leverantörsfaktura' + default: + return intentId + } +} diff --git a/app/(dashboard)/chat/intake/page.tsx b/app/(dashboard)/chat/intake/page.tsx new file mode 100644 index 00000000..6bf80036 --- /dev/null +++ b/app/(dashboard)/chat/intake/page.tsx @@ -0,0 +1,24 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { getActiveCompanyId } from '@/lib/company/context' +import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter' + +export const dynamic = 'force-dynamic' + +// /chat/intake — Phase C bootstrap surface. ReviewCard navigates here after +// Phase B "kör" succeeds. The client component mounts AgentChat with +// intent='onboarding.intake' in fresh-start mode; AgentChat auto-fires the +// first invoke which creates the conversation row, and we swap the URL to +// /chat/[id] when the new id streams back. +// +// Plan ref: dev_docs/specialized-agent-plan.md §7 Phase C. +export default async function ChatIntakePage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) redirect('/onboarding') + + return +} diff --git a/app/(dashboard)/chat/layout.tsx b/app/(dashboard)/chat/layout.tsx new file mode 100644 index 00000000..1883ec0b --- /dev/null +++ b/app/(dashboard)/chat/layout.tsx @@ -0,0 +1,54 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { getActiveCompanyId } from '@/lib/company/context' +import ChatSidebar from '@/components/agent/ChatSidebar' + +export const dynamic = 'force-dynamic' + +// Two-pane chat layout: sidebar with conversations on the left, active +// conversation (or empty state) in the main panel. Both /chat and /chat/[id] +// share this layout so the sidebar doesn't unmount on conversation switches. +export default async function ChatLayout({ children }: { children: React.ReactNode }) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) redirect('/onboarding') + + // Block the chat surface until the agent is built. Without this a user + // who deep-links to /chat (bookmark, ⌘K, "+ Ny" elsewhere) lands on an + // empty conversations list with no Anna to talk to. The home route at / + // renders NewUserChecklist for the same state, so we forward there + // instead of duplicating the welcome screen here. + const { data: agent } = await supabase + .from('agent_profiles') + .select('verified_at') + .eq('company_id', companyId) + .maybeSingle() + if (!agent?.verified_at) redirect('/') + + const { data: conversations } = await supabase + .from('agent_conversations') + .select( + 'id, intent_id, context_ref, title, pinned, archived, last_message_at, last_message_preview, created_at', + ) + .eq('company_id', companyId) + .eq('archived', false) + .order('pinned', { ascending: false }) + .order('last_message_at', { ascending: false, nullsFirst: false }) + .limit(100) + + return ( + // MainContainer hands /chat a full-bleed h-full wrapper, so we don't + // need negative margins to break out of any chrome padding. + // + // dvh handles mobile browser chrome shrinking on scroll. Mobile: subtract + // the bottom nav (h-16 = 64px) + safe-area-inset-bottom so the chat + // pane fills the visible viewport exactly. Desktop: full viewport. +
+ +
{children}
+
+ ) +} diff --git a/app/(dashboard)/chat/new/page.tsx b/app/(dashboard)/chat/new/page.tsx new file mode 100644 index 00000000..1de7cecf --- /dev/null +++ b/app/(dashboard)/chat/new/page.tsx @@ -0,0 +1,36 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import { getActiveCompanyId } from '@/lib/company/context' +import ChatNewStarter from '@/components/agent/ChatNewStarter' +import { getIntent } from '@/lib/agent/intents/registry' + +export const dynamic = 'force-dynamic' + +interface PageProps { + searchParams: Promise<{ intent?: string; prompt?: string }> +} + +// /chat/new — generic conversation bootstrap. Reads ?intent= and ?prompt= +// from the URL and mounts AgentChat in fresh mode; AgentChat creates the +// conversation server-side on first invoke and the client swaps the URL +// to /chat/[id] when the id streams back. Mirrors /chat/intake but with +// caller-chosen intent/seed, so suggestion chips and ⌘K can route here +// inline instead of opening the slide-in sheet. +export default async function ChatNewPage({ searchParams }: PageProps) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const companyId = await getActiveCompanyId(supabase, user.id) + if (!companyId) redirect('/onboarding') + + const sp = await searchParams + const requested = typeof sp.intent === 'string' && sp.intent.trim() ? sp.intent.trim() : 'general.help' + // Validate against the registry — a bogus ?intent= would otherwise render the + // chat shell and then fail at invoke with a 400, which reads as "Anna is broken" + // rather than "bad link". Fall back to general help instead. + const intent = getIntent(requested) ? requested : 'general.help' + const prompt = typeof sp.prompt === 'string' ? sp.prompt : '' + + return +} diff --git a/app/(dashboard)/chat/page.tsx b/app/(dashboard)/chat/page.tsx new file mode 100644 index 00000000..06348c80 --- /dev/null +++ b/app/(dashboard)/chat/page.tsx @@ -0,0 +1,10 @@ +import ChatEmptyState from '@/components/agent/ChatEmptyState' + +export const dynamic = 'force-dynamic' + +// Empty state for /chat. The sidebar (in the layout) shows the list; this +// view appears when no specific conversation is selected. Offers an obvious +// entry to start a fresh general.help conversation. +export default function ChatIndexPage() { + return +} diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index de42d711..c189b26f 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -30,6 +30,7 @@ import CustomerForm from '@/components/customers/CustomerForm' import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog' import { FirstInvoiceLogoPrompt } from '@/components/invoices/FirstInvoiceLogoPrompt' import { useCompany } from '@/contexts/CompanyContext' +import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { ROT_WORK_TYPES, RUT_WORK_TYPES, @@ -599,7 +600,7 @@ export default function NewInvoicePage() { -
+

{titleText} {numberPreview && ( @@ -610,6 +611,11 @@ export default function NewInvoicePage() {

{subtitleText}

+
{hasBankDetails === false && ( diff --git a/app/(dashboard)/kpi/page.tsx b/app/(dashboard)/kpi/page.tsx index 81d1d7f5..e1b238b3 100644 --- a/app/(dashboard)/kpi/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -83,11 +83,13 @@ export default function KpiPage() {

{t('title')}

- +
+ +
- -
- -
-
- {children} -
-
-
+ + +
+ +
+
+ {children} +
+
+
+
) } @@ -136,27 +141,35 @@ export default async function DashboardLayout({ return ( - -
- -
-
- {children} -
-
-
+ + +
+ +
+
+ {children} +
+
+
+
) } - const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([ + const [ + { data: settings }, + { count: uncategorizedCount }, + { count: pendingOpsCount }, + { data: agentProfileIdentity }, + { data: userProfile }, + ] = await Promise.all([ supabase .from('company_settings') .select('company_name, onboarding_complete, entity_type, is_sandbox') @@ -172,6 +185,17 @@ export default async function DashboardLayout({ .select('*', { count: 'exact', head: true }) .eq('company_id', companyId) .eq('status', 'pending'), + // Agent identity — name + avatar — surfaced on the FAB and chat + // surfaces. Null when no agent_profile exists yet (banner CTA path). + supabase + .from('agent_profiles') + .select('display_name, avatar_id, verified_at') + .eq('company_id', companyId) + .maybeSingle(), + // The signed-in user's profile — shown in the bottom-left account + // popover (full_name + initial) so it's clear which user is logged + // in, distinct from the active company shown at the top. + supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(), ]) // If onboarding incomplete, still render the dashboard — the page component @@ -203,28 +227,40 @@ export default async function DashboardLayout({ return ( - -
- {/* Skip to content link for keyboard/screen reader users */} - - Hoppa till innehåll - - {isSandbox && } - -
- {children} -
-
+ + +
+ {/* Skip to content link for keyboard/screen reader users */} + + Hoppa till innehåll + + {isSandbox && } + +
+ {children} +
+ + +
+
) } diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index e2d73958..5b6af597 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -2,12 +2,18 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { cookies } from 'next/headers' import DashboardContent from '@/components/dashboard/DashboardContent' +import WelcomeGate from '@/components/onboarding/WelcomeGate' import { getActiveCompanyId } from '@/lib/company/context' import { getDisplayTotal } from '@/lib/invoices/rounding' import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' export const dynamic = 'force-dynamic' +// Home route = Översikt (DashboardContent). The agent chat has its own nav +// entry at /chat, so / no longer forwards there. New users who haven't built +// their assistant yet get WelcomeGate (the build-agent checklist) instead of +// the dashboard; once the agent is verified, / renders the normal Översikt. + export default async function DashboardPage() { const supabase = await createClient() @@ -76,6 +82,7 @@ export default async function DashboardPage() { { count: staleUncategorizedCount }, { count: uncategorizedCount }, { count: skatteverketTokenCount }, + { data: agentProfile }, { data: noDocRequiredEntries }, ] = await Promise.all([ supabase.from('company_settings').select('*').eq('company_id', companyId).single(), @@ -106,6 +113,7 @@ export default async function DashboardPage() { // carry the active company_id; either filter would work — we use user_id // because that's what the token-store reads/writes against. supabase.from('skatteverket_tokens').select('*', { count: 'exact', head: true }).eq('user_id', user.id), + supabase.from('agent_profiles').select('verified_at').eq('company_id', companyId).maybeSingle(), supabase.from('journal_entry_no_doc_required').select('journal_entry_id').eq('company_id', companyId), ]) @@ -114,6 +122,34 @@ export default async function DashboardPage() { redirect('/onboarding') } + const agentBuilt = Boolean(agentProfile?.verified_at) + + // "Has the company already been used?" Any real business data means we must + // NOT hijack the dashboard with the full-screen onboarding gate — existing + // and migrated users get the normal Översikt with a build-assistant prompt + // in the hero slot (see DashboardContent's agentBuilt branch) instead. + const hasData = + (transactionCount || 0) > 0 || + (sieImportCount || 0) > 0 || + (invoiceCount || 0) > 0 || + (receiptCount || 0) > 0 || + (customerCount || 0) > 0 || + (postedEntriesCount || 0) > 0 + + // Only a genuinely empty company without an assistant sees the full + // onboarding checklist (where building the assistant is the last step). + // Everyone else falls through to the dashboard below. + if (!agentBuilt && !hasData) { + return ( + 0} + hasBankConnected={(transactionCount || 0) > 0} + hasSkatteverketConnected={(skatteverketTokenCount || 0) > 0} + /> + ) + } + const onboardingProgress: OnboardingProgress = { hasCustomers: (customerCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0, @@ -252,6 +288,7 @@ export default async function DashboardPage() { return ( = { + // Low/medium risk — light verifikation work create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.', create_customer: 'Genom att klicka godkänn så skapar du en kund.', create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).', @@ -95,15 +116,60 @@ const singleActionWarnings: Record = { send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.', mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.', mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.', - create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt verifikationsnummer.', - correct_entry: 'Genom att klicka godkänn så bokförs en storno och en ny korrigerad verifikation i samma period (BFL 5 kap 5§).', - reverse_entry: 'Genom att klicka godkänn så makuleras verifikationen via en storno i samma period.', + // High risk — period/year-end/voucher edits. These are the ones the reviewer + // really needs the warning for, so we keep them concrete: name the + // irreversibility or compliance consequence, not the generic risk-level. + lock_period: 'Genom att klicka godkänn så låses perioden — inga nya verifikationer kan bokföras tills den låses upp.', + unlock_period: 'Genom att klicka godkänn så låses perioden upp. Använd endast för rättelser; lås igen efter.', + close_period: 'Genom att klicka godkänn så stängs perioden permanent (BFL). Stängningen kan inte ångras.', + run_year_end: 'Genom att klicka godkänn så körs bokslut: resultatkonton nollställs, perioden låses, nästa period skapas.', + set_opening_balances: 'Genom att klicka godkänn så bokförs ingående balans i nästa period.', + run_currency_revaluation: 'Genom att klicka godkänn så bokförs valutaomvärdering (3960/7960).', + create_voucher: 'Genom att klicka godkänn så bokförs verifikationen med ett nytt löpnummer.', + correct_entry: 'Genom att klicka godkänn så stornas originalverifikationen och en rättelse bokförs (BFL 5 kap 5§).', + reverse_entry: 'Genom att klicka godkänn så stornas verifikationen — originalet behålls synligt (BFL 5 kap).', + credit_invoice: 'Genom att klicka godkänn så skapas en kreditfaktura och originalverifikationen stornas.', + credit_supplier_invoice: 'Genom att klicka godkänn så krediteras leverantörsfakturan och registreringsverifikationen stornas.', + approve_supplier_invoice: 'Genom att klicka godkänn så attesteras leverantörsfakturan och blir betalningsbar.', + convert_invoice: 'Genom att klicka godkänn så konverteras proformafakturan till en riktig faktura med F-nummer.', + import_sie: 'Genom att klicka godkänn så importeras SIE-filen: räkenskapsperiod, ingående balans och verifikationer skapas.', + explain_voucher_gap: 'Genom att klicka godkänn så dokumenteras förklaringen för verifikationsluckan (BFNAR 2013:2).', + post_annual_depreciation: 'Genom att klicka godkänn så bokförs planenlig avskrivning — en verifikation per tillgång.', } function singleActionWarning(operationType: string): string { return singleActionWarnings[operationType] ?? '' } +// Period status carried inside preview_data when stagePendingOperation can +// resolve it. Shape mirrors PeriodStatusForDate in lib/core/bookkeeping/period-service.ts. +interface PeriodStatusShape { + period_id: string | null + status: 'open' | 'locked' | 'closed' + lock_date: string | null +} + +function getPeriodStatus(op: PendingOperation): PeriodStatusShape | null { + const raw = (op.preview_data as Record)?.period_status + if (!raw || typeof raw !== 'object') return null + const obj = raw as Record + const status = obj.status + if (status !== 'open' && status !== 'locked' && status !== 'closed') return null + return { + period_id: typeof obj.period_id === 'string' ? obj.period_id : null, + status, + lock_date: typeof obj.lock_date === 'string' ? obj.lock_date : null, + } +} + +const REJECTION_CATEGORY_LABELS: Record = { + wrong_category: 'Fel kategori / konto', + wrong_amount: 'Fel belopp', + duplicate: 'Dubblett', + wrong_period: 'Fel period', + other: 'Annat', +} + function formatRelativeTime(dateStr: string): string { const now = new Date() const date = new Date(dateStr) @@ -365,7 +431,9 @@ function renderPrimitive(value: unknown): string { } function GenericPreview({ data }: { data: Record }) { - const entries = Object.entries(data).filter(([, v]) => v != null && v !== '') + // Skip period_status here — it's surfaced in the dedicated banner, not the + // generic key-value dump (otherwise the approver sees the same fact twice). + const entries = Object.entries(data).filter(([k, v]) => v != null && v !== '' && k !== 'period_status') return (
{entries.map(([key, value]) => ( @@ -406,6 +474,34 @@ function OperationPreview({ op }: { op: PendingOperation }) { return body } +/** + * Inline period-lock banner. Renders when the staged operation touches a + * period that's already locked or closed — the server's commit-time trigger + * will reject it, so we tell the approver up front rather than letting them + * click and see a generic "Misslyckades" toast. The fiscal_period_id link + * goes to the periods management page where unlocking is possible. + */ +function PeriodLockBanner({ period }: { period: PeriodStatusShape }) { + const lockedThrough = period.lock_date ? formatDate(period.lock_date) : null + return ( +
+ +
+

+ {period.status === 'closed' + ? 'Perioden är stängd permanent (BFL) — kan inte ändras.' + : `Perioden är låst${lockedThrough ? ` t.o.m. ${lockedThrough}` : ''}.`} +

+

+ {period.status === 'closed' + ? 'Använd en omprövning i en öppen period i stället.' + : 'Lås upp perioden via Bokföring → Räkenskapsperioder, ändra entry-datum, eller avvisa.'} +

+
+
+ ) +} + type SourceFilter = 'all' | 'agent' | 'high_risk' const sourceFilterLabels = ( @@ -425,6 +521,7 @@ export default function PendingOperationsPage() { const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('pending') const [sourceFilter, setSourceFilter] = useState('all') + const [conversationFilter, setConversationFilter] = useState(null) const [counts, setCounts] = useState({ pending: null, committed: null, @@ -437,8 +534,22 @@ export default function PendingOperationsPage() { const [selectedIds, setSelectedIds] = useState>(new Set()) const [showBulkDialog, setShowBulkDialog] = useState(false) const [isBulkCommitting, setIsBulkCommitting] = useState(false) + // Reject dialog state — separate from the generic destructive-confirm so we + // can ask for a category + free-text reason that feeds back to the agent. + const [rejectOp, setRejectOp] = useState(null) + const [rejectCategory, setRejectCategory] = useState('') + const [rejectReason, setRejectReason] = useState('') + const [isRejecting, setIsRejecting] = useState(false) const { toast } = useToast() - const { dialogProps, confirm } = useDestructiveConfirm() + + // Read ?conversation= once on mount so deep-links from the agent context + // strip filter the list automatically. + useEffect(() => { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + const conv = url.searchParams.get('conversation') + if (conv) setConversationFilter(conv) + }, []) const fetchOperations = useCallback(async () => { setIsLoading(true) @@ -479,10 +590,34 @@ export default function PendingOperationsPage() { fetchAllCounts() }, [fetchAllCounts]) + // Realtime subscription: refetch when ANY pending_operations row changes for + // this company. RLS scopes the channel automatically — we don't see other + // tenants' events. We refetch the whole list (rather than patching state + // in-place) so server-side filtering, sorting, and computed fields stay in + // sync with whatever the API route returned. The counts endpoint isn't + // pushed by the same trigger, so we also refresh counts on every change. + useEffect(() => { + const supabase = createClient() + const channel = supabase + .channel('pending_operations:list') + .on( + 'postgres_changes', + { event: '*', schema: 'public', table: 'pending_operations' }, + () => { + fetchOperations() + fetchAllCounts() + } + ) + .subscribe() + return () => { + void supabase.removeChannel(channel) + } + }, [fetchOperations, fetchAllCounts]) + // Clear selection when filters/tab change useEffect(() => { setSelectedIds(new Set()) - }, [activeTab, sourceFilter]) + }, [activeTab, sourceFilter, conversationFilter]) async function handleCommit() { if (!selectedOp) return @@ -552,27 +687,51 @@ export default function PendingOperationsPage() { setIsBulkCommitting(false) } - async function handleReject(op: PendingOperation) { - const ok = await confirm({ - title: 'Avvisa operation?', - description: `"${op.title}" kommer att avvisas.`, - confirmLabel: 'Avvisa', - variant: 'destructive', - }) - if (!ok) return + function openRejectDialog(op: PendingOperation) { + setRejectOp(op) + setRejectCategory('') + setRejectReason('') + } + async function handleReject() { + if (!rejectOp) return + setIsRejecting(true) try { - const res = await fetch(`/api/pending-operations/${op.id}/reject`, { method: 'POST' }) - if (!res.ok) throw new Error('Misslyckades') - toast({ title: 'Avvisad', description: op.title }) + const body = + rejectCategory || rejectReason.trim() + ? { + ...(rejectCategory ? { rejection_category: rejectCategory } : {}), + ...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}), + } + : undefined + const res = await fetch(`/api/pending-operations/${rejectOp.id}/reject`, { + method: 'POST', + ...(body + ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : {}), + }) + if (!res.ok) { + const json = await res.json().catch(() => ({})) + throw new Error(json.error || 'Misslyckades') + } + toast({ title: 'Avvisad', description: rejectOp.title }) + setRejectOp(null) fetchOperations() fetchAllCounts() - } catch { - toast({ title: 'Kunde inte avvisa', variant: 'destructive' }) + } catch (err) { + toast({ + title: 'Kunde inte avvisa', + description: err instanceof Error ? err.message : 'Okänt fel', + variant: 'destructive', + }) } + setIsRejecting(false) } const filteredOperations = operations.filter((op) => { + if (conversationFilter && op.agent_metadata?.conversation_id !== conversationFilter) { + return false + } switch (sourceFilter) { case 'agent': return op.actor_type === 'api_key' || op.actor_type === 'mcp_oauth' || op.actor_type === 'cron' @@ -585,8 +744,19 @@ export default function PendingOperationsPage() { }) const showBulkControls = activeTab === 'pending' + // Pending ops that meet two criteria: not high risk AND the period covering + // them is open. We exclude locked/closed periods from bulk because they will + // be rejected at commit time anyway — silently letting the user "select all" + // and watching some fail is a worse UX than excluding them up front. const bulkEligible = useMemo( - () => filteredOperations.filter((op) => op.status === 'pending' && op.risk_level !== 'high'), + () => + filteredOperations.filter((op) => { + if (op.status !== 'pending') return false + if (op.risk_level === 'high') return false + const period = getPeriodStatus(op) + if (period && period.status !== 'open') return false + return true + }), [filteredOperations] ) const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible]) @@ -594,6 +764,9 @@ export default function PendingOperationsPage() { bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id)) const someSelected = bulkEligibleIds.some((id) => selectedIds.has(id)) + const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length + const excludedFromBulk = pendingTotal - bulkEligible.length + function toggleSelected(id: string) { setSelectedIds((prev) => { const next = new Set(prev) @@ -654,6 +827,33 @@ export default function PendingOperationsPage() { description={t('subtitle')} /> + {conversationFilter && ( +
+
+ + + {t('conversation_filter_label')}{' '} + #{conversationFilter.slice(0, 8)} + +
+ +
+ )} +
setActiveTab(v as PendingOperationStatus)}> @@ -703,11 +903,19 @@ export default function PendingOperationsPage() {
- {typeCounts.length > 0 && selectedCount === 0 && ( + {/* Only worth showing when there's more than one type to pick from — + with a single type it just duplicates "Markera alla". */} + {typeCounts.length >= 2 && selectedCount === 0 && (
{t('quick_pick')} {typeCounts.map(([type, count]) => { @@ -745,7 +953,9 @@ export default function PendingOperationsPage() { disabled={selectedCount === 0 || isBulkCommitting} onClick={() => setShowBulkDialog(true)} > - {t('approve_selected', { count: selectedCount })} + {selectedCount > 0 + ? t('approve_selected', { count: selectedCount }) + : t('approve_selected_none')}
@@ -776,9 +986,16 @@ export default function PendingOperationsPage() { ? { label: t(entry.labelKey), icon: entry.icon, variant: entry.variant } : { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const } const isExpanded = expandedId === op.id - const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high' + const period = getPeriodStatus(op) + const periodLocked = period != null && period.status !== 'open' + const canBulkSelect = + showBulkControls && op.status === 'pending' && op.risk_level !== 'high' && !periodLocked const isSelected = selectedIds.has(op.id) const isAgent = op.actor_type && op.actor_type !== 'user' + const conversationId = op.agent_metadata?.conversation_id ?? null + const warningSentence = singleActionWarning(op.operation_type) + const showHighRiskWarning = + op.risk_level === 'high' && warningSentence && op.status === 'pending' return ( { e.stopPropagation() + if (periodLocked) return setSelectedOp(op) setShowCommitDialog(true) }} @@ -817,7 +1037,7 @@ export default function PendingOperationsPage() { className="h-8 px-3 text-xs" onClick={(e) => { e.stopPropagation() - handleReject(op) + openRejectDialog(op) }} > {t('reject')} @@ -825,7 +1045,18 @@ export default function PendingOperationsPage() { ) : undefined } - expandedContent={} + expandedContent={ + <> + {/* Period-lock banner sits ABOVE the preview so the reviewer + sees the blocker as soon as they expand the row. */} + {periodLocked && period && op.status === 'pending' && ( +
+ +
+ )} + + + } > {op.title} @@ -835,7 +1066,19 @@ export default function PendingOperationsPage() { - {op.actor_label || op.actor_type} + {/* The actor label doubles as the deep-link into the + originating conversation — no separate strip needed. */} + {conversationId ? ( + e.stopPropagation()} + > + {op.actor_label || op.actor_type} + + ) : ( + op.actor_label || op.actor_type + )} )} @@ -847,6 +1090,18 @@ export default function PendingOperationsPage() { )} + {showHighRiskWarning && ( +

+ + {warningSentence} +

+ )} + {op.status === 'rejected' && op.rejection_category && ( +

+ Avvisad: {REJECTION_CATEGORY_LABELS[op.rejection_category]} + {op.rejection_reason ? ` — "${op.rejection_reason}"` : ''} +

+ )}
) }) @@ -892,8 +1147,62 @@ export default function PendingOperationsPage() {
- {/* Reject confirmation dialog */} - + {/* Reject dialog — category + free-text reason. Both optional so the user + can still reject quickly without filling anything in. */} + { if (!open) setRejectOp(null) }}> + + + Avvisa operation + + {rejectOp?.title} + + +
+
+ + +
+
+ +