feat(calendar): enable calendar sync for Viktiga datum (#1853)
Turns on the calendar extension (built Feb 2026, stripped in the 2026-03-02 production readiness deploy, never re-enabled): ICS feed settings, calendar workspace, subscribe button on the Viktiga datum page. Hardening before first real use: feed serve route now requires the creator to still be a company member (offboarding stops the feed); stable pagination (due_date + id, dedupe) on feed queries; fetches inside the logged try block; invoice events limited to sent/paid/partially_paid/overdue; event UIDs rebranded to accounted.se while zero feeds exist; APP_URL fallback fails closed in production; mobile stacking for the deadlines header; settings note that Google Calendar needs default notifications on subscribed calendars; calendar workspace aligned with the design system. Skeptic reviewed (3 refutations, all fixed) plus one compliance swarm finding (fixed). No migrations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4fb449a474
commit
f75ea2384d
@@ -14,7 +14,8 @@ import { HelpPopover } from '@/components/ui/help-popover'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Lock, Plus } from 'lucide-react'
|
||||
import { CalendarPlus, Lock, Plus } from 'lucide-react'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
@@ -430,14 +431,26 @@ export default function DeadlinesPage() {
|
||||
</HelpPopover>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_deadline')}
|
||||
</Button>
|
||||
// Stacks full-width on mobile: PageHeader's [&>*]:w-full lands on
|
||||
// this wrapper, so the buttons themselves must go w-full below sm
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center [&>*]:w-full sm:[&>*]:w-auto">
|
||||
{ENABLED_EXTENSION_IDS.has('calendar') && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/settings/account">
|
||||
<CalendarPlus className="mr-2 h-4 w-4" />
|
||||
{t('subscribe_calendar')}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setShowForm(true)}
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('read_only_tooltip') : undefined}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_deadline')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Tests for /api/calendar/feed/[token] (public ICS serve route).
|
||||
*
|
||||
* The membership check matters most: the token is the only authentication,
|
||||
* so removal from company_members must stop the feed. Without the check an
|
||||
* offboarded consultant's subscribed calendar keeps receiving the company's
|
||||
* deadlines and invoice details indefinitely.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
vi.mock('@/lib/supabase/service-client', () => ({
|
||||
createServiceRoleClient: () => supabase,
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const FEED = {
|
||||
id: 'feed-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
is_active: true,
|
||||
expires_at: null,
|
||||
access_count: 0,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: false,
|
||||
}
|
||||
|
||||
function tokenRequest(token: string) {
|
||||
return [
|
||||
new Request(`http://localhost/api/calendar/feed/${token}`),
|
||||
{ params: Promise.resolve({ token }) },
|
||||
] as const
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
|
||||
})
|
||||
|
||||
describe('GET /api/calendar/feed/[token]', () => {
|
||||
it('returns 400 for a non-UUID token', async () => {
|
||||
const [req, ctx] = tokenRequest('not-a-uuid')
|
||||
const res = await GET(req, ctx)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when no active feed matches the token', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
const [req, ctx] = tokenRequest('11111111-1111-1111-1111-111111111111')
|
||||
const res = await GET(req, ctx)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 when the feed creator is no longer a company member', async () => {
|
||||
enqueue({ data: FEED }) // calendar_feeds lookup
|
||||
enqueue({ data: null }) // company_members lookup: offboarded
|
||||
const [req, ctx] = tokenRequest('22222222-2222-2222-2222-222222222222')
|
||||
const res = await GET(req, ctx)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('serves an ICS calendar while the creator is still a member', async () => {
|
||||
enqueue({ data: FEED }) // calendar_feeds lookup
|
||||
enqueue({ data: { user_id: 'user-1' } }) // company_members lookup
|
||||
enqueue({ data: null }) // access tracking update
|
||||
enqueue({ data: [] }) // deadlines page 1 (empty: pagination stops)
|
||||
const [req, ctx] = tokenRequest('33333333-3333-3333-3333-333333333333')
|
||||
const res = await GET(req, ctx)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toContain('text/calendar')
|
||||
expect(await res.text()).toContain('BEGIN:VCALENDAR')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createServiceRoleClient } from '@/lib/supabase/service-client'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import type { Deadline, Invoice } from '@/types'
|
||||
|
||||
const log = createLogger('api/calendar/feed-token')
|
||||
|
||||
@@ -78,6 +80,21 @@ export async function GET(
|
||||
return new NextResponse('Feed token has expired', { status: 410 })
|
||||
}
|
||||
|
||||
// The token authenticates the feed, but the feed's creator must still be a
|
||||
// member of the company: offboarding (removal from company_members) must
|
||||
// stop the feed, or an ex-member's subscribed calendar keeps receiving the
|
||||
// company's deadlines and invoice details indefinitely.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('user_id')
|
||||
.eq('company_id', feed.company_id)
|
||||
.eq('user_id', feed.user_id)
|
||||
.maybeSingle()
|
||||
|
||||
if (!membership) {
|
||||
return new NextResponse('Feed not found or inactive', { status: 404 })
|
||||
}
|
||||
|
||||
// Update access tracking
|
||||
await supabase
|
||||
.from('calendar_feeds')
|
||||
@@ -97,36 +114,55 @@ export async function GET(
|
||||
const startStr = startDate.toISOString().split('T')[0]
|
||||
const endStr = endDate.toISOString().split('T')[0]
|
||||
|
||||
// Fetch relevant data based on feed options. Deadlines are always
|
||||
// fetched: include_tax_deadlines only hides SYSTEM rows (the generator
|
||||
// filters by source), while user-created deadlines always appear.
|
||||
const [deadlinesResult, invoicesResult] = await Promise.all([
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('company_id', feed.company_id)
|
||||
.is('dismissed_at', null)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date'),
|
||||
|
||||
// Invoices
|
||||
feed.include_invoices
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('company_id', feed.company_id)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
: { data: [] },
|
||||
])
|
||||
|
||||
try {
|
||||
// Fetch relevant data based on feed options. Deadlines are always
|
||||
// fetched: include_tax_deadlines only hides SYSTEM rows (the generator
|
||||
// filters by source), while user-created deadlines always appear.
|
||||
// The secondary .order('id') gives the stable total order paging
|
||||
// requires: due dates cluster hard (invoice batches, tax deadlines), so
|
||||
// ordering by due_date alone leaves the page boundary inside a run of
|
||||
// tied rows, where Postgres may drop or repeat rows between pages.
|
||||
const [deadlines, invoices] = await Promise.all([
|
||||
fetchAllRows<Deadline>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('company_id', feed.company_id)
|
||||
.is('dismissed_at', null)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
{ dedupeBy: (row) => row.id }
|
||||
),
|
||||
|
||||
// Invoices with a real due date to remind about: drafts, cancelled and
|
||||
// credited invoices have no payable due date and would leak
|
||||
// speculative amounts into the subscriber's calendar.
|
||||
feed.include_invoices
|
||||
? fetchAllRows<Invoice>(
|
||||
({ from, to }) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('company_id', feed.company_id)
|
||||
.in('status', ['sent', 'paid', 'partially_paid', 'overdue'])
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
.order('id')
|
||||
.range(from, to),
|
||||
{ dedupeBy: (row) => row.id }
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
const icsContent = await generateCalendarFeed(
|
||||
{
|
||||
deadlines: deadlinesResult.data || [],
|
||||
invoices: invoicesResult.data || [],
|
||||
deadlines,
|
||||
invoices,
|
||||
},
|
||||
{
|
||||
includeTaxDeadlines: feed.include_tax_deadlines,
|
||||
@@ -137,7 +173,7 @@ export async function GET(
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="erp-base.ics"',
|
||||
'Content-Disposition': 'attachment; filename="accounted.ics"',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
|
||||
@@ -20,7 +20,13 @@ const UpdateFeedSchema = z
|
||||
)
|
||||
|
||||
function feedUrls(feedToken: string) {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.erp-base.se'
|
||||
// Fail closed in production: an http:// fallback would mint a link that
|
||||
// carries the feed's bearer token over an unencrypted channel.
|
||||
const envUrl = process.env.NEXT_PUBLIC_APP_URL
|
||||
if (!envUrl && process.env.NODE_ENV === 'production') {
|
||||
throw new Error('NEXT_PUBLIC_APP_URL must be set in production')
|
||||
}
|
||||
const baseUrl = envUrl || 'http://localhost:3000'
|
||||
return {
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feedToken}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feedToken}`,
|
||||
|
||||
@@ -230,6 +230,10 @@ export function CalendarFeedSettings() {
|
||||
</SettingsRowEnd>
|
||||
</SettingsRow>
|
||||
|
||||
<p className="px-1 pt-2 text-xs text-muted-foreground">
|
||||
{t('google_reminders_note')}
|
||||
</p>
|
||||
|
||||
<SettingsRow label={t('create_new_link')} help={t('regen_help')}>
|
||||
{/* Live feed stats stay visible: they are state, not instructions */}
|
||||
{feed.last_accessed_at && (
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","mail"]}
|
||||
{"$schema":"./extensions.schema.json","extensions":["calendar","enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox","woocommerce","shopify","mail"]}
|
||||
@@ -57,7 +57,7 @@ export function CalendarDayCell({
|
||||
!isCurrentMonth && 'bg-muted/30 text-muted-foreground',
|
||||
isToday && 'bg-primary/5',
|
||||
hasOverdue && 'border-l-2 border-l-destructive',
|
||||
!hasOverdue && hasActionNeeded && 'border-l-2 border-l-orange-500'
|
||||
!hasOverdue && hasActionNeeded && 'border-l-2 border-l-warning'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
@@ -113,7 +113,7 @@ export function CalendarDayCell({
|
||||
{deadlinesByStatus.action_needed > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-sm', STATUS_COLORS.action_needed.dot)} />
|
||||
<span className="text-xs text-orange-700 truncate">
|
||||
<span className="text-xs text-warning-foreground truncate">
|
||||
{deadlinesByStatus.action_needed} åtgärd
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function CalendarDayView({
|
||||
<div
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
'p-2 rounded-lg',
|
||||
isInvoiceOverdue(invoice)
|
||||
? 'bg-destructive/10 border border-destructive/30'
|
||||
: 'bg-primary/10 border border-primary/30'
|
||||
@@ -110,7 +110,7 @@ export function CalendarDayView({
|
||||
)}>
|
||||
Faktura {invoice.invoice_number}
|
||||
{isInvoiceOverdue(invoice) && (
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded-sm">
|
||||
Förfallen
|
||||
</span>
|
||||
)}
|
||||
@@ -131,14 +131,14 @@ export function CalendarDayView({
|
||||
{paidInvoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30"
|
||||
className="p-2 rounded-lg bg-success/10 border border-success/30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-success flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-success">
|
||||
Faktura {invoice.invoice_number}
|
||||
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded">
|
||||
<span className="ml-2 text-xs bg-success/20 px-1.5 py-0.5 rounded-sm">
|
||||
Betald
|
||||
</span>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export function CalendarDayView({
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
'p-2 rounded-lg',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/10 border border-destructive/30'
|
||||
: 'bg-warning/10 border border-warning/30'
|
||||
@@ -177,7 +177,7 @@ export function CalendarDayView({
|
||||
)}>
|
||||
{deadline.title}
|
||||
{isDeadlineOverdue(deadline) && (
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded">
|
||||
<span className="ml-2 text-xs bg-destructive/20 px-1.5 py-0.5 rounded-sm">
|
||||
Försenad
|
||||
</span>
|
||||
)}
|
||||
@@ -186,9 +186,9 @@ export function CalendarDayView({
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
|
||||
{deadline.priority !== 'normal' && (
|
||||
<span className={cn(
|
||||
'ml-2 px-1.5 py-0.5 rounded',
|
||||
'ml-2 px-1.5 py-0.5 rounded-sm',
|
||||
deadline.priority === 'critical' && 'bg-destructive/10 text-destructive',
|
||||
deadline.priority === 'important' && 'bg-orange-100 text-orange-700'
|
||||
deadline.priority === 'important' && 'bg-warning/10 text-warning-foreground'
|
||||
)}>
|
||||
{PRIORITY_LABELS[deadline.priority]}
|
||||
</span>
|
||||
@@ -208,7 +208,7 @@ export function CalendarDayView({
|
||||
{completedDeadlines.map((deadline) => (
|
||||
<div
|
||||
key={deadline.id}
|
||||
className="p-2 rounded-md bg-success/10 border border-success/30 opacity-60"
|
||||
className="p-2 rounded-lg bg-success/10 border border-success/30 opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-sm bg-success flex-shrink-0" />
|
||||
@@ -218,7 +218,7 @@ export function CalendarDayView({
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{DEADLINE_TYPE_LABELS[deadline.deadline_type] || deadline.deadline_type}
|
||||
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded">Klar</span>
|
||||
<span className="ml-2 bg-success/20 px-1.5 py-0.5 rounded-sm">Klar</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function CalendarWeekView({
|
||||
<div
|
||||
key={invoice.id}
|
||||
className={cn(
|
||||
'text-xs p-1 mb-1 rounded truncate',
|
||||
'text-xs p-1 mb-1 rounded-sm truncate',
|
||||
isInvoiceOverdue(invoice)
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-primary/20 text-primary'
|
||||
@@ -111,7 +111,7 @@ export function CalendarWeekView({
|
||||
<div
|
||||
key={deadline.id}
|
||||
className={cn(
|
||||
'text-xs p-1 mb-1 rounded truncate',
|
||||
'text-xs p-1 mb-1 rounded-sm truncate',
|
||||
isDeadlineOverdue(deadline)
|
||||
? 'bg-destructive/20 text-destructive'
|
||||
: 'bg-warning/20 text-warning-foreground'
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { CalendarViewMode } from '@/types'
|
||||
import { VIEW_MODE_LABELS } from '@/lib/calendar/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
|
||||
interface ViewModeSelectorProps {
|
||||
viewMode: CalendarViewMode
|
||||
@@ -14,21 +13,11 @@ export function ViewModeSelector({ viewMode, onViewModeChange }: ViewModeSelecto
|
||||
const modes: CalendarViewMode[] = ['month', 'week', 'day']
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-md border">
|
||||
{modes.map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange(mode)}
|
||||
className={cn(
|
||||
'rounded-none border-r last:border-r-0 px-3',
|
||||
viewMode === mode && 'bg-muted'
|
||||
)}
|
||||
>
|
||||
{VIEW_MODE_LABELS[mode]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={viewMode}
|
||||
onChange={onViewModeChange}
|
||||
options={modes.map((mode) => ({ value: mode, label: VIEW_MODE_LABELS[mode] }))}
|
||||
aria-label="Kalendervy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface CalendarData {
|
||||
* Generate a stable UID for calendar events
|
||||
* This ensures updates to events are recognized by calendar apps
|
||||
*/
|
||||
function getEventUID(type: string, id: string, domain: string = 'erp-base.se'): string {
|
||||
function getEventUID(type: string, id: string, domain: string = 'accounted.se'): string {
|
||||
return `${type}-${id}@${domain}`
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
// AUTO-GENERATED: do not edit. Run `npm run setup:extensions` to regenerate.
|
||||
|
||||
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
|
||||
'calendar',
|
||||
'enable-banking',
|
||||
'email',
|
||||
'arcim-migration',
|
||||
|
||||
+2
@@ -1,5 +1,6 @@
|
||||
// AUTO-GENERATED: do not edit. Run `npm run setup:extensions` to regenerate.
|
||||
import type { Extension } from '../types'
|
||||
import { calendarExtension } from '@/extensions/general/calendar'
|
||||
import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
||||
import { emailExtension } from '@/extensions/general/email'
|
||||
import { arcimMigrationExtension } from '@/extensions/general/arcim-migration'
|
||||
@@ -16,6 +17,7 @@ import { shopifyExtension } from '@/extensions/general/shopify'
|
||||
import { mailExtension } from '@/extensions/general/mail'
|
||||
|
||||
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
calendarExtension,
|
||||
enableBankingExtension,
|
||||
emailExtension,
|
||||
arcimMigrationExtension,
|
||||
|
||||
+15
@@ -3,6 +3,21 @@ import type { ExtensionDefinition } from '../types'
|
||||
|
||||
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
|
||||
'general': [
|
||||
{
|
||||
"slug": "calendar",
|
||||
"name": "Kalender",
|
||||
"sector": "general",
|
||||
"category": "operations",
|
||||
"icon": "Calendar",
|
||||
"dataPattern": "core",
|
||||
"description": "Fullständig kalendervy med månads-, vecko- och dagsvisning",
|
||||
"longDescription": "Se alla fakturadatum och deadlines i en interaktiv kalender med månads-, vecko- och dagsvy.",
|
||||
"readsCoreTables": [
|
||||
"invoices",
|
||||
"deadlines",
|
||||
"customers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "enable-banking",
|
||||
"name": "Bankintegration (PSD2)",
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ import type { ComponentType } from 'react'
|
||||
import type { WorkspaceComponentProps } from '../workspace-registry'
|
||||
|
||||
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
|
||||
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')),
|
||||
'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')),
|
||||
|
||||
@@ -776,6 +776,7 @@
|
||||
"group_cancel": "Cancel",
|
||||
"group_confirm": "Confirm",
|
||||
"new_deadline": "New deadline",
|
||||
"subscribe_calendar": "Subscribe in your calendar",
|
||||
"read_only_tooltip": "You have read-only access in this company",
|
||||
"generating": "Generating…",
|
||||
"help_text": "Deadlines for VAT, employer declarations and F-tax are generated automatically from your company's tax settings. Add your own with New deadline; click a row to edit it.",
|
||||
@@ -2903,6 +2904,7 @@
|
||||
"add_to_apple_calendar": "Add to Apple Calendar",
|
||||
"calendar_link_label": "Calendar link (for Google Calendar etc.)",
|
||||
"calendar_link_help": "Copy this link and add it as a URL subscription in your calendar app.",
|
||||
"google_reminders_note": "Google Calendar does not show reminders from subscribed calendars. Set default notifications on the subscribed calendar in Google Calendar to get alerts.",
|
||||
"last_fetched": "Last fetched:",
|
||||
"times_count": "{count} times",
|
||||
"creating_new_link": "Creating new link...",
|
||||
|
||||
@@ -776,6 +776,7 @@
|
||||
"group_cancel": "Avbryt",
|
||||
"group_confirm": "Bekräfta",
|
||||
"new_deadline": "Ny deadline",
|
||||
"subscribe_calendar": "Prenumerera i din kalender",
|
||||
"read_only_tooltip": "Du har endast läsbehörighet i detta företag",
|
||||
"generating": "Genererar…",
|
||||
"help_text": "Deadlines för moms, arbetsgivardeklaration och F-skatt skapas automatiskt från företagets skatteinställningar. Egna deadlines lägger du till med Ny deadline; klicka på en rad för att ändra den.",
|
||||
@@ -2903,6 +2904,7 @@
|
||||
"add_to_apple_calendar": "Lägg till i Apple Calendar",
|
||||
"calendar_link_label": "Kalenderlänk (för Google Calendar m.fl.)",
|
||||
"calendar_link_help": "Kopiera denna länk och lägg till som URL-prenumeration i din kalenderapp.",
|
||||
"google_reminders_note": "Google Calendar visar inte påminnelser från prenumererade kalendrar. Sätt standardaviseringar på den prenumererade kalendern i Google Calendar för att få notiser.",
|
||||
"last_fetched": "Senast hämtad:",
|
||||
"times_count": "{count} gånger",
|
||||
"creating_new_link": "Skapar ny länk...",
|
||||
|
||||
Reference in New Issue
Block a user