feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry (#858)
* feat(dimensions): PR2 registry — CRUD API, register UI, settings toggle, SIE export on the new registry
Phase 2 of dev_docs/dimensions_implementation_plan.md. Companies with
dimensions_enabled=false (default) see zero change.
API:
- Dashboard CRUD: GET /api/dimensions (lazy-seeds system dims 1/6 via the
ensure_company_dimensions RPC), PATCH /api/dimensions/[id] (is_system
rename blocked), POST/PATCH/DELETE values (code immutable after creation;
strict Fortnox code format ^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$ at the API layer;
retention-trigger deletes surface the Swedish "arkivera istället" message
as 409 DIMENSION_VALUE_REFERENCED).
- POST /api/dimensions/import-existing — scans journal_entry_lines.dimensions
for unregistered codes and mints inactive placeholder registry rows.
- v1 public API: GET dimensions + POST values (Idempotency-Key, dry-run),
registered in the OpenAPI spec (102→104 endpoints).
- dimensions_enabled boolean on company_settings (new migration,
UI-visibility only, never correctness-bearing) exposed through the
existing settings read/update path.
SIE export (lib/reports/sie-export.ts):
- Reads the new dimensions/dimension_values registry; legacy
cost_centers/projects tables now have zero readers (drop migration next).
- Fixes the latent Visma-rejection bug: #OBJEKT now declared for INACTIVE
values referenced by lines.
- Generic-N: #DIM/#UNDERDIM loop sorted by sie_dim_no; #TRANS object lists
serialize from the line JSONB map (sorted, '01'→'1' collapse); orphan
codes/dims synthesize declarations from the SIE reserved-number seed —
every referenced (dim, code) pair is guaranteed declared.
UI:
- /dimensions register (Register-recipe): tabs per dimension, search,
sortable table, value dialog (code immutable on edit, projekt dates on
dim 6), archive-not-delete affordances.
- DimensionCombobox shipped (mounts in the tagging PR).
- Settings toggle "Aktivera kostnadsställen & projekt" — toggle-on runs the
import-existing scan and links to the register.
- Nav row in redovisning, rendered only when dimensions_enabled (same
mechanism as pays_salaries).
- dimensions.* i18n namespace (51 keys, sv/en parity).
- Sandbox seed: demo dims + values, revenue line tagged {"1":"BUTIK","6":"P001"}.
Verified: 6328/6328 unit tests, guard + coverage gate green, tsc parity with
main (210=210), production build passes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): PR2 review round — atomic archived-create, UNDERDIM ordering, import robustness, date semantics
- POST values accepts is_active so "create as archived" is atomic; the UI's
fragile create-then-PATCH fallback is deleted (PR Agent finding 1).
- DimensionCombobox blur revert reads the committed value/values through refs
so a selection landing inside the 150ms window always wins (finding 2).
- import-existing sanitizes candidate codes like the PR1 backfill and upserts
with ignoreDuplicates — one bad/duplicate code can no longer abort the
batch; created counted from returned rows (finding 3).
- SIE export emits all root #DIM before any #UNDERDIM so a parent always
precedes a lower-numbered child (SIE4 declaration order — Swedish review);
synthesized placeholder declarations now log one structured warning
(BFNAR 2013:2 behandlingshistorik) + defence-in-depth comment.
- Value dates rejected (400 DIMENSION_VALUE_DATES_NOT_ALLOWED) when the
parent dimension is flow-period (resets_annually=true); explicit null
still clears (Swedish review).
- Sandbox seed logs seeded dimension codes; GET /api/dimensions documents
the deliberate absence of dimensions_enabled gating (UI-visibility flag,
not a security boundary — compliance-swarm V8.2.1 rejected by design).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import DimensionsManager from '@/components/dimensions/DimensionsManager'
|
||||
|
||||
/**
|
||||
* Kostnadsställen & projekt (dimension registry) — a Redovisning-group
|
||||
* register peer to Kontoplan. Reference/configuration surface: manage the
|
||||
* dimension values (#OBJEKT) that voucher lines are tagged with. Reachable
|
||||
* only via the nav row when company_settings.dimensions_enabled is on, but
|
||||
* the page itself never gates — the toggle is UI visibility, not correctness
|
||||
* (dimensions plan §2).
|
||||
*/
|
||||
export default async function DimensionsPage() {
|
||||
const t = await getTranslations('nav')
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader title={t('dimensions')} />
|
||||
<DimensionsManager />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ export default async function DashboardLayout({
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox')
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
// Shared worklist predicates (lib/worklist) — the badge must show the
|
||||
@@ -226,6 +226,10 @@ export default async function DashboardLayout({
|
||||
(companyRow.entity_type as EntityType) ||
|
||||
'enskild_firma'
|
||||
const paysSalaries = settings?.pays_salaries ?? false
|
||||
// Dimensions register visibility (Kostnadsställen & projekt nav row). Same
|
||||
// mechanism as paysSalaries: UI gate only, never load-bearing for
|
||||
// correctness (dimensions plan §2).
|
||||
const dimensionsEnabled = settings?.dimensions_enabled ?? false
|
||||
const companyWithName = {
|
||||
...companyRow,
|
||||
name: displayName,
|
||||
@@ -292,6 +296,7 @@ export default async function DashboardLayout({
|
||||
companyName={settings?.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
paysSalaries={paysSalaries}
|
||||
dimensionsEnabled={dimensionsEnabled}
|
||||
uncategorizedTransactionCount={uncategorizedCount}
|
||||
pendingOperationsCount={pendingOpsCount}
|
||||
isSandbox={isSandbox}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PATCH /api/dimensions/[id] — update a dimension (name / is_active / sort_order).
|
||||
*
|
||||
* Guard rails:
|
||||
* - Renaming an is_system dimension (1 = Kostnadsställe, 6 = Projekt) is
|
||||
* rejected with 400 DIMENSION_SYSTEM_RENAME ("Systemdimensioner kan inte
|
||||
* döpas om"). Archiving (is_active=false) and reordering remain allowed.
|
||||
* - sie_dim_no / is_system are immutable at the DB level
|
||||
* (enforce_dimension_registry_guards) and not accepted here at all.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateDimensionSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const PATCH = withRouteContext(
|
||||
'dimension.update',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ dimensionId: id })
|
||||
|
||||
const result = await validateBody(request, UpdateDimensionSchema, {
|
||||
log: opLog,
|
||||
operation: 'dimension.update',
|
||||
})
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, name, is_system')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
opLog.error('dimension fetch failed', fetchError)
|
||||
return errorResponse(fetchError, opLog, { requestId })
|
||||
}
|
||||
if (!existing) {
|
||||
return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
if (existing.is_system && body.name !== undefined && body.name !== existing.name) {
|
||||
return errorResponseFromCode('DIMENSION_SYSTEM_RENAME', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Sparse update — only the fields the caller actually sent.
|
||||
const updateData: Record<string, unknown> = {}
|
||||
for (const key of ['name', 'is_active', 'sort_order'] as const) {
|
||||
if (body[key] !== undefined) updateData[key] = body[key]
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('dimensions')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
opLog.error('dimension update failed', error)
|
||||
return errorResponseFromCode('DIMENSION_UPDATE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* PATCH /api/dimensions/[id]/values/[valueId] — update a dimension value
|
||||
* (name / is_active / start_date / end_date; `code` is immutable in v1).
|
||||
* DELETE /api/dimensions/[id]/values/[valueId] — delete an UNREFERENCED value.
|
||||
*
|
||||
* Deleting a value referenced by posted/reversed lines is blocked by the DB
|
||||
* retention trigger (enforce_dimension_value_retention, BFL 7-year
|
||||
* philosophy). Its Swedish message ("Värdet "X" används på bokförda verifikat
|
||||
* och kan inte tas bort — arkivera det istället.") is surfaced verbatim as a
|
||||
* 409 DIMENSION_VALUE_REFERENCED so the register UI can toast it and offer
|
||||
* archive instead.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateDimensionValueSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
type ValueParams = { params: Promise<{ id: string; valueId: string }> }
|
||||
|
||||
export const PATCH = withRouteContext(
|
||||
'dimension.value.update',
|
||||
async (request, ctx, { params }: ValueParams) => {
|
||||
const { id, valueId } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ dimensionId: id, valueId })
|
||||
|
||||
const result = await validateBody(request, UpdateDimensionValueSchema, {
|
||||
log: opLog,
|
||||
operation: 'dimension.value.update',
|
||||
})
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
// Value dates only make sense on accumulating dimensions (projekt-style
|
||||
// ranges). When the request carries an actual date (explicit null = clear,
|
||||
// always allowed), check the parent dimension's resets_annually flag.
|
||||
if (body.start_date != null || body.end_date != null) {
|
||||
const { data: dimension, error: dimError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, resets_annually')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (dimError) {
|
||||
opLog.error('dimension fetch failed', dimError)
|
||||
return errorResponse(dimError, opLog, { requestId })
|
||||
}
|
||||
if (!dimension) {
|
||||
return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
if (dimension.resets_annually) {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_DATES_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
// Sparse update — only the fields the caller actually sent. `code` is
|
||||
// deliberately absent from the schema: renaming a code would silently
|
||||
// orphan every line tagged with it.
|
||||
const updateData: Record<string, unknown> = {}
|
||||
for (const key of ['name', 'is_active', 'start_date', 'end_date'] as const) {
|
||||
if (body[key] !== undefined) updateData[key] = body[key]
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('dimension_values')
|
||||
.update(updateData)
|
||||
.eq('id', valueId)
|
||||
.eq('dimension_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
opLog.error('dimension value update failed', error)
|
||||
return errorResponseFromCode('DIMENSION_VALUE_UPDATE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
export const DELETE = withRouteContext(
|
||||
'dimension.value.delete',
|
||||
async (_request, ctx, { params }: ValueParams) => {
|
||||
const { id, valueId } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ dimensionId: id, valueId })
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('dimension_values')
|
||||
.delete()
|
||||
.eq('id', valueId)
|
||||
.eq('dimension_id', id)
|
||||
.eq('company_id', companyId)
|
||||
.select('id')
|
||||
|
||||
if (error) {
|
||||
// P0001 = plpgsql RAISE EXCEPTION — the retention trigger refusing the
|
||||
// delete. Surface its Swedish message verbatim (it names the code).
|
||||
if (error.code === 'P0001') {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_REFERENCED', opLog, {
|
||||
requestId,
|
||||
messageSv: error.message,
|
||||
})
|
||||
}
|
||||
opLog.error('dimension value delete failed', error)
|
||||
return errorResponseFromCode('DIMENSION_VALUE_DELETE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
})
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* POST /api/dimensions/[id]/values — create a dimension value (SIE #OBJEKT).
|
||||
*
|
||||
* Codes are validated against the strict Fortnox format
|
||||
* (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values — the DB CHECK is
|
||||
* looser by design so legacy free-text codes survive the backfill/SIE import,
|
||||
* but new registry codes minted here stay portable. Duplicate codes within the
|
||||
* dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE with a Swedish message.
|
||||
* `code` is immutable after creation (v1: no rename — retag instead).
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'dimension.value.create',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
const opLog = log.child({ dimensionId: id })
|
||||
|
||||
const result = await validateBody(request, CreateDimensionValueSchema, {
|
||||
log: opLog,
|
||||
operation: 'dimension.value.create',
|
||||
})
|
||||
if (!result.success) return result.response
|
||||
const body = result.data
|
||||
|
||||
// The dimension must exist and belong to the active company (defense in
|
||||
// depth alongside the composite FK — a foreign dimension id 404s here).
|
||||
const { data: dimension, error: dimError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, resets_annually')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (dimError) {
|
||||
opLog.error('dimension fetch failed', dimError)
|
||||
return errorResponse(dimError, opLog, { requestId })
|
||||
}
|
||||
if (!dimension) {
|
||||
return errorResponseFromCode('DIMENSION_NOT_FOUND', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Value dates only make sense on accumulating dimensions (projekt-style
|
||||
// ranges). A resets-annually dimension (e.g. kostnadsställe) rejects any
|
||||
// request carrying an actual date (explicit null is a harmless no-op).
|
||||
if (dimension.resets_annually && (body.start_date != null || body.end_date != null)) {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_DATES_NOT_ALLOWED', opLog, { requestId })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('dimension_values')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
dimension_id: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
// Default true; is_active=false makes "create as archived" atomic
|
||||
// (no follow-up PATCH from the register UI).
|
||||
is_active: body.is_active ?? true,
|
||||
start_date: body.start_date ?? null,
|
||||
end_date: body.end_date ?? null,
|
||||
})
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return errorResponseFromCode('DIMENSION_VALUE_DUPLICATE_CODE', opLog, {
|
||||
requestId,
|
||||
details: { code: body.code },
|
||||
})
|
||||
}
|
||||
opLog.error('dimension value insert failed', error)
|
||||
return errorResponseFromCode('DIMENSION_VALUE_CREATE_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: error.message },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Tests for PATCH /api/dimensions/[id] (update dimension).
|
||||
*
|
||||
* Covers: 401, empty-body validation (400), 404, the is_system name-lock
|
||||
* ("Systemdimensioner kan inte döpas om" — 400 DIMENSION_SYSTEM_RENAME),
|
||||
* archiving a system dimension (allowed), and the happy rename of a custom
|
||||
* dimension.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const requireWriteMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: (...args: unknown[]) => requireWriteMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { PATCH } from '../[id]/route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'dim-1' })
|
||||
|
||||
describe('PATCH /api/dimensions/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
requireWriteMock.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Avdelning' },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an empty body with 400', async () => {
|
||||
const request = createMockRequest('/api/dimensions/dim-1', { method: 'PATCH', body: {} })
|
||||
const response = await PATCH(request, params())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the dimension does not belong to the company', async () => {
|
||||
enqueue({ data: null }) // maybeSingle fetch
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Avdelning' },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects renaming a system dimension with 400 DIMENSION_SYSTEM_RENAME', async () => {
|
||||
enqueue({ data: { id: 'dim-1', name: 'Kostnadsställe', is_system: true } })
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Avdelning' },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('DIMENSION_SYSTEM_RENAME')
|
||||
expect(body.error.message).toBe('Systemdimensioner kan inte döpas om.')
|
||||
})
|
||||
|
||||
it('allows archiving a system dimension (is_active only, no rename)', async () => {
|
||||
enqueue({ data: { id: 'dim-1', name: 'Kostnadsställe', is_system: true } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe',
|
||||
resets_annually: true, is_system: true, is_active: false, sort_order: 10,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1', {
|
||||
method: 'PATCH',
|
||||
body: { is_active: false },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { is_active: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_active).toBe(false)
|
||||
})
|
||||
|
||||
it('renames a non-system dimension (happy path)', async () => {
|
||||
enqueue({ data: { id: 'dim-1', name: 'Dimension 7', is_system: false } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'dim-1', sie_dim_no: 7, name: 'Avdelning',
|
||||
resets_annually: true, is_system: false, is_active: true, sort_order: 30,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1', {
|
||||
method: 'PATCH',
|
||||
body: { name: 'Avdelning', sort_order: 30 },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { name: string; sort_order: number } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.name).toBe('Avdelning')
|
||||
expect(body.data.sort_order).toBe(30)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Tests for POST /api/dimensions/import-existing (backfill scan).
|
||||
*
|
||||
* Covers: 401, the empty-company early exit, the happy path where codes
|
||||
* found on journal lines but missing from the registry are created as
|
||||
* inactive placeholder values ({ created: n }), code sanitization (PR1
|
||||
* backfill parity), and duplicate-tolerant upserts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../import-existing/route'
|
||||
|
||||
const request = () => createMockRequest('/api/dimensions/import-existing', { method: 'POST' })
|
||||
const noParams = { params: Promise.resolve({}) }
|
||||
|
||||
describe('POST /api/dimensions/import-existing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns { created: 0 } when no line carries dimensions', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ data: [] }) // journal_entry_lines scan
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.created).toBe(0)
|
||||
})
|
||||
|
||||
it('creates inactive placeholder values for codes missing from the registry', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
// Lines: KS01 (dim 1) appears twice, P001 (dim 6) once, BUTIK already registered.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } },
|
||||
{ id: 'l3', dimensions: { '1': 'BUTIK' } },
|
||||
],
|
||||
})
|
||||
// Registry dims 1 & 6 exist (system dims).
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1 },
|
||||
{ id: 'dim-6', sie_dim_no: 6 },
|
||||
],
|
||||
})
|
||||
// Existing values: BUTIK is already registered under dim 1.
|
||||
enqueue({ data: [{ dimension_id: 'dim-1', code: 'BUTIK' }] })
|
||||
// Upsert of the two missing values succeeds — created counts returned rows.
|
||||
enqueue({ data: [{ id: 'nv1' }, { id: 'nv2' }] })
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.created).toBe(2) // KS01 + P001; BUTIK skipped (already registered)
|
||||
})
|
||||
|
||||
it('sanitizes candidate codes like the PR1 backfill and de-duplicates after sanitization', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
// 'KS"01"' and 'KS{01}' both sanitize to KS01 (one candidate); a 50-char
|
||||
// code is capped at 40; '"{}"' sanitizes to empty and is dropped entirely.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS"01"' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS{01}' } },
|
||||
{ id: 'l3', dimensions: { '1': 'X'.repeat(50) } },
|
||||
{ id: 'l4', dimensions: { '1': '"{}"' } },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
|
||||
enqueue({ data: [] }) // no existing values
|
||||
// Upsert returns the two surviving sanitized codes (KS01 + the capped one).
|
||||
enqueue({ data: [{ id: 'nv1' }, { id: 'nv2' }] })
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.created).toBe(2)
|
||||
})
|
||||
|
||||
it('tolerates duplicates in the batch — created counts only the rows the upsert returned', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'l1', dimensions: { '1': 'KS01' } },
|
||||
{ id: 'l2', dimensions: { '1': 'KS02' } },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims
|
||||
enqueue({ data: [] }) // existing-values snapshot missed a raced KS02
|
||||
// ignoreDuplicates upsert skips the conflicting row instead of aborting
|
||||
// the batch — only KS01 comes back.
|
||||
enqueue({ data: [{ id: 'nv1' }] })
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.created).toBe(1)
|
||||
})
|
||||
|
||||
it('creates a registry dimension for an unregistered dim number found on lines', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines
|
||||
// Registry only has the system dims — dim 7 is missing.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1 },
|
||||
{ id: 'dim-6', sie_dim_no: 6 },
|
||||
],
|
||||
})
|
||||
// Upsert of the missing dimension row returns its id.
|
||||
enqueue({ data: [{ id: 'dim-7', sie_dim_no: 7 }] })
|
||||
// No existing values.
|
||||
enqueue({ data: [] })
|
||||
// Value upsert succeeds.
|
||||
enqueue({ data: [{ id: 'nv1' }] })
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ created: number }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.created).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 500 DIMENSION_IMPORT_FAILED when the scan blows up', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({ error: { message: 'relation missing' } }) // fetchAllRows throws
|
||||
|
||||
const response = await POST(request(), noParams)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('DIMENSION_IMPORT_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for GET /api/dimensions (dimension registry list).
|
||||
*
|
||||
* Exercises the route through the real withRouteContext wrapper, mocking only
|
||||
* its auth/company dependencies and injecting a queued Supabase mock via
|
||||
* requireAuth. Covers: 401, the ensure-RPC failure path, and the contract
|
||||
* shape ({ dimensions: [...] } with values nested per dimension).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
interface DimensionsBody {
|
||||
dimensions: Array<{
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
values: Array<{
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
describe('GET /api/dimensions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) })
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('ensures system dims exist, then returns dimensions with nested values', async () => {
|
||||
// 1st DB hit: ensure_company_dimensions RPC.
|
||||
enqueue({ data: null })
|
||||
// 2nd: dimensions list (sorted by sort_order server-side).
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
|
||||
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
|
||||
],
|
||||
})
|
||||
// 3rd: dimension_values list (sorted by code server-side).
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken', is_active: true, start_date: null, end_date: null },
|
||||
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Projekt Björk', is_active: false, start_date: '2026-01-01', end_date: '2026-06-30' },
|
||||
],
|
||||
})
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<DimensionsBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(supabase.rpc).toHaveBeenCalledWith('ensure_company_dimensions', {
|
||||
p_company_id: 'company-1',
|
||||
})
|
||||
expect(body.dimensions).toHaveLength(2)
|
||||
expect(body.dimensions[0]).toMatchObject({
|
||||
id: 'dim-1',
|
||||
sie_dim_no: 1,
|
||||
name: 'Kostnadsställe',
|
||||
resets_annually: true,
|
||||
is_system: true,
|
||||
values: [
|
||||
{ id: 'v1', code: 'BUTIK', name: 'Butiken', is_active: true, start_date: null, end_date: null },
|
||||
],
|
||||
})
|
||||
expect(body.dimensions[1].values).toEqual([
|
||||
{ id: 'v2', code: 'P001', name: 'Projekt Björk', is_active: false, start_date: '2026-01-01', end_date: '2026-06-30' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a dimension with an empty values array when it has no values', async () => {
|
||||
enqueue({ data: null }) // ensure RPC
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
|
||||
],
|
||||
})
|
||||
enqueue({ data: [] })
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<DimensionsBody>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.dimensions[0].values).toEqual([])
|
||||
})
|
||||
|
||||
it('returns 500 when the ensure RPC fails', async () => {
|
||||
enqueue({ error: { code: 'XX000', message: 'boom' } })
|
||||
|
||||
const response = await GET(createMockRequest('/api/dimensions'), { params: Promise.resolve({}) })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Tests for PATCH/DELETE /api/dimensions/[id]/values/[valueId].
|
||||
*
|
||||
* The DELETE suite pins the retention-trigger contract: a P0001 raise from
|
||||
* enforce_dimension_value_retention surfaces as 409 DIMENSION_VALUE_REFERENCED
|
||||
* with the trigger's own Swedish message ("…arkivera det istället") so the UI
|
||||
* can toast it verbatim.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { PATCH, DELETE } from '../[id]/values/[valueId]/route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'dim-1', valueId: 'v1' })
|
||||
const URL_PATH = '/api/dimensions/dim-1/values/v1'
|
||||
|
||||
describe('PATCH /api/dimensions/[id]/values/[valueId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'PATCH', body: { name: 'Nytt namn' } })
|
||||
const response = await PATCH(request, params())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('rejects an empty body with 400', async () => {
|
||||
const request = createMockRequest(URL_PATH, { method: 'PATCH', body: {} })
|
||||
const response = await PATCH(request, params())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the value does not exist in the company', async () => {
|
||||
enqueue({ error: { code: 'PGRST116', message: 'No rows found' } })
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'PATCH', body: { name: 'Nytt namn' } })
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('archives a value via is_active=false (happy path)', async () => {
|
||||
// The request carries end_date, so the route first checks the parent
|
||||
// dimension's resets_annually flag (accumulating → dates allowed).
|
||||
enqueue({ data: { id: 'dim-1', resets_annually: false } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken',
|
||||
is_active: false, start_date: null, end_date: '2026-06-30',
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest(URL_PATH, {
|
||||
method: 'PATCH',
|
||||
body: { is_active: false, end_date: '2026-06-30' },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { is_active: boolean; code: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.is_active).toBe(false)
|
||||
// Code untouched — immutable in v1.
|
||||
expect(body.data.code).toBe('BUTIK')
|
||||
})
|
||||
|
||||
it('rejects start/end dates on a resets-annually dimension with 400', async () => {
|
||||
enqueue({ data: { id: 'dim-1', resets_annually: true } })
|
||||
|
||||
const request = createMockRequest(URL_PATH, {
|
||||
method: 'PATCH',
|
||||
body: { start_date: '2026-01-01' },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_DATES_NOT_ALLOWED')
|
||||
expect(body.error.message).toBe(
|
||||
'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).',
|
||||
)
|
||||
})
|
||||
|
||||
it('allows clearing dates (explicit null) without checking the dimension', async () => {
|
||||
// start_date: null clears the field — a harmless no-op on any dimension,
|
||||
// so the route must not spend a dimension fetch on it. The single queued
|
||||
// result feeds the update itself.
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'v1', dimension_id: 'dim-1', code: 'BUTIK', name: 'Butiken',
|
||||
is_active: true, start_date: null, end_date: null,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest(URL_PATH, {
|
||||
method: 'PATCH',
|
||||
body: { start_date: null, end_date: null },
|
||||
})
|
||||
const response = await PATCH(request, params())
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/dimensions/[id]/values/[valueId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'DELETE' })
|
||||
const response = await DELETE(request, params())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('surfaces the retention trigger as 409 with the trigger\'s Swedish message', async () => {
|
||||
const triggerMessage =
|
||||
'Värdet "BUTIK" används på bokförda verifikat och kan inte tas bort — arkivera det istället.'
|
||||
enqueue({ error: { code: 'P0001', message: triggerMessage } })
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'DELETE' })
|
||||
const response = await DELETE(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_REFERENCED')
|
||||
// The trigger's message (naming the code) is surfaced verbatim for the toast.
|
||||
expect(body.error.message).toBe(triggerMessage)
|
||||
expect(body.error.message).toContain('arkivera det istället')
|
||||
})
|
||||
|
||||
it('returns 404 when nothing was deleted', async () => {
|
||||
enqueue({ data: [] })
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'DELETE' })
|
||||
const response = await DELETE(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('deletes an unreferenced value (happy path)', async () => {
|
||||
enqueue({ data: [{ id: 'v1' }] })
|
||||
|
||||
const request = createMockRequest(URL_PATH, { method: 'DELETE' })
|
||||
const response = await DELETE(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tests for POST /api/dimensions/[id]/values (create dimension value).
|
||||
*
|
||||
* Covers: 401, the strict Fortnox code format (400), 404 on a foreign
|
||||
* dimension, the duplicate-code conflict (409 with Swedish message), the
|
||||
* dates-on-resets-annually rejection (400), atomic create-as-archived
|
||||
* (is_active=false), and the happy path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
|
||||
import { POST } from '../[id]/values/route'
|
||||
|
||||
const params = () => createMockRouteParams({ id: 'dim-1' })
|
||||
|
||||
describe('POST /api/dimensions/[id]/values', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase })
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'P001', name: 'Projekt Björk' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['space in code', 'P 001'],
|
||||
['too long (>20 chars)', 'A'.repeat(21)],
|
||||
['SIE-breaking char', 'P{1}'],
|
||||
])('rejects an invalid code (%s) with 400', async (_label, code) => {
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code, name: 'Projekt' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects end_date before start_date with 400', async () => {
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'P001', name: 'Projekt', start_date: '2026-06-01', end_date: '2026-01-01' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when the dimension does not belong to the company', async () => {
|
||||
enqueue({ data: null }) // dimension maybeSingle
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'P001', name: 'Projekt Björk' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects start/end dates on a resets-annually dimension with 400', async () => {
|
||||
// Kostnadsställe-style dims reset annually — value date ranges are only
|
||||
// meaningful on accumulating dims (projekt).
|
||||
enqueue({ data: { id: 'dim-1', resets_annually: true } })
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'KS01', name: 'Kontoret', start_date: '2026-01-01' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_DATES_NOT_ALLOWED')
|
||||
expect(body.error.message).toBe(
|
||||
'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).',
|
||||
)
|
||||
})
|
||||
|
||||
it('creates a value as archived when is_active=false (atomic, no follow-up PATCH)', async () => {
|
||||
enqueue({ data: { id: 'dim-1', resets_annually: false } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'v2', dimension_id: 'dim-1', code: 'NEDLAGD', name: 'Nedlagd avdelning',
|
||||
is_active: false, start_date: null, end_date: null,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'NEDLAGD', name: 'Nedlagd avdelning', is_active: false },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; is_active: boolean } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('v2')
|
||||
expect(body.data.is_active).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 409 with a Swedish message on a duplicate code', async () => {
|
||||
enqueue({ data: { id: 'dim-1' } })
|
||||
enqueue({ error: { code: '23505', message: 'duplicate key value' } })
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'P001', name: 'Projekt Björk' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_DUPLICATE_CODE')
|
||||
expect(body.error.message).toBe('Ett värde med samma kod finns redan i dimensionen.')
|
||||
})
|
||||
|
||||
it('creates a value (happy path, Swedish chars allowed in code)', async () => {
|
||||
enqueue({ data: { id: 'dim-1' } })
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'v1', dimension_id: 'dim-1', code: 'GÖTEBORG', name: 'Göteborgskontoret',
|
||||
is_active: true, start_date: null, end_date: null,
|
||||
},
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/dimensions/dim-1/values', {
|
||||
method: 'POST',
|
||||
body: { code: 'GÖTEBORG', name: 'Göteborgskontoret' },
|
||||
})
|
||||
const response = await POST(request, params())
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string; code: string } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.id).toBe('v1')
|
||||
expect(body.data.code).toBe('GÖTEBORG')
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* GET /api/dimensions — the dimension registry (kostnadsställe/projekt + custom
|
||||
* dims) with nested values, for the register page and pickers.
|
||||
*
|
||||
* Calls ensure_company_dimensions first so the system dims (1 = Kostnadsställe,
|
||||
* 6 = Projekt) always exist — lazy seeding keeps core zero-config for companies
|
||||
* that never touch dimensions (dev_docs/dimensions_implementation_plan.md §6).
|
||||
*
|
||||
* Response contract (PR2 — the register UI builds against this exactly):
|
||||
* 200 { dimensions: [{ id, sie_dim_no, name, resets_annually, is_system,
|
||||
* is_active, sort_order, values: [{ id, code, name, is_active,
|
||||
* start_date, end_date }] }] }
|
||||
* Dimensions sorted by sort_order, values by code.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface DimensionValueRow {
|
||||
id: string
|
||||
dimension_id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
interface DimensionRow {
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'dimension.list',
|
||||
async (_request, ctx) => {
|
||||
// dimensions_enabled is deliberately NOT enforced here — it is a
|
||||
// UI-visibility flag only (dev_docs/dimensions_implementation_plan.md §2).
|
||||
// Agents/MCP and SIE import must operate on the registry regardless of the
|
||||
// toggle; the security boundary is company scoping (withRouteContext + RLS).
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const { error: ensureError } = await supabase.rpc('ensure_company_dimensions', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
if (ensureError) {
|
||||
log.error('ensure_company_dimensions failed', ensureError)
|
||||
return errorResponse(ensureError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: dims, error: dimsError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||||
.eq('company_id', companyId)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('sie_dim_no', { ascending: true })
|
||||
|
||||
if (dimsError) {
|
||||
log.error('dimension list failed', dimsError)
|
||||
return errorResponse(dimsError, log, { requestId })
|
||||
}
|
||||
|
||||
const { data: values, error: valuesError } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', companyId)
|
||||
.order('code', { ascending: true })
|
||||
|
||||
if (valuesError) {
|
||||
log.error('dimension value list failed', valuesError)
|
||||
return errorResponse(valuesError, log, { requestId })
|
||||
}
|
||||
|
||||
const valuesByDimension = new Map<string, Omit<DimensionValueRow, 'dimension_id'>[]>()
|
||||
for (const v of (values ?? []) as DimensionValueRow[]) {
|
||||
const bucket = valuesByDimension.get(v.dimension_id) ?? []
|
||||
bucket.push({
|
||||
id: v.id,
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
is_active: v.is_active,
|
||||
start_date: v.start_date,
|
||||
end_date: v.end_date,
|
||||
})
|
||||
valuesByDimension.set(v.dimension_id, bucket)
|
||||
}
|
||||
|
||||
const dimensions = ((dims ?? []) as DimensionRow[]).map((d) => ({
|
||||
...d,
|
||||
values: valuesByDimension.get(d.id) ?? [],
|
||||
}))
|
||||
|
||||
return NextResponse.json({ dimensions })
|
||||
},
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import { createLogger } from '@/lib/logger'
|
||||
import { checkRateLimit } from '@/lib/auth/rate-limit-http'
|
||||
import { truncateIp } from '@/lib/api/v1/with-api-v1'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import { lineDimensionColumns } from '@/lib/bookkeeping/dimension-resolver'
|
||||
|
||||
// Anonymous sign-in is enabled in all environments so visitors can try the
|
||||
// product; a per-/24 cap on the seed endpoint keeps a single network from
|
||||
@@ -146,6 +147,8 @@ export async function POST(request: Request) {
|
||||
onboarding_step: 6,
|
||||
onboarding_complete: true,
|
||||
is_sandbox: true,
|
||||
// Dimensions demo — the register/pickers render out of the box.
|
||||
dimensions_enabled: true,
|
||||
})
|
||||
|
||||
if (settingsError) throw settingsError
|
||||
@@ -157,6 +160,39 @@ export async function POST(request: Request) {
|
||||
})
|
||||
if (coaError) throw coaError
|
||||
|
||||
// 3b. Seed demo dimensions (kostnadsställe/projekt). ensure_company_dimensions
|
||||
// lazily creates the system dims 1/6; two values per dim give the register,
|
||||
// pickers, and the dimension-tagged journal lines below something to show.
|
||||
const { error: dimsRpcError } = await supabase.rpc('ensure_company_dimensions', {
|
||||
p_company_id: companyId,
|
||||
})
|
||||
if (dimsRpcError) throw dimsRpcError
|
||||
|
||||
const { data: demoDims, error: demoDimsError } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no')
|
||||
.eq('company_id', companyId)
|
||||
.in('sie_dim_no', [1, 6])
|
||||
if (demoDimsError) throw demoDimsError
|
||||
|
||||
const dimIdByNo = Object.fromEntries(
|
||||
(demoDims ?? []).map(d => [d.sie_dim_no as number, d.id as string])
|
||||
) as Record<number, string>
|
||||
|
||||
if (dimIdByNo[1] && dimIdByNo[6]) {
|
||||
const seededDimensionCodes = ['BUTIK', 'WEBB', 'P001', 'P002']
|
||||
const { error: dimValuesError } = await supabase
|
||||
.from('dimension_values')
|
||||
.insert([
|
||||
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'BUTIK', name: 'Butiken' },
|
||||
{ company_id: companyId, dimension_id: dimIdByNo[1], code: 'WEBB', name: 'Webbshoppen' },
|
||||
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P001', name: 'Projekt Björk' },
|
||||
{ company_id: companyId, dimension_id: dimIdByNo[6], code: 'P002', name: 'Projekt Alm' },
|
||||
])
|
||||
if (dimValuesError) throw dimValuesError
|
||||
log.info('seeded sandbox dimension values', { companyId, codes: seededDimensionCodes })
|
||||
}
|
||||
|
||||
// 4. Create fiscal period (current year)
|
||||
const currentYear = new Date().getFullYear()
|
||||
const { data: fiscalPeriod, error: fpError } = await supabase
|
||||
@@ -424,11 +460,20 @@ export async function POST(request: Request) {
|
||||
|
||||
if (je2Error) throw je2Error
|
||||
|
||||
// 10. Create journal entry lines
|
||||
// 10. Create journal entry lines. The P&L line carries demo dimensions
|
||||
// ({"1":"BUTIK","6":"P001"}) so the register's "antal taggade rader",
|
||||
// voucher-detail badges, and the dimension P&L report light up in the
|
||||
// sandbox. Mirror columns derived via lineDimensionColumns — never set
|
||||
// independently of the dimensions map.
|
||||
const revenueDims = { '1': 'BUTIK', '6': 'P001' }
|
||||
const { error: jelError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert([
|
||||
// JE1: Invoice creation — Debit AR, Credit Revenue + VAT
|
||||
// NB: `dimensions` must be set explicitly on EVERY row — same PostgREST
|
||||
// bulk-insert normalization as paid_amount below: omitting it on some
|
||||
// rows while one row sets it sends null (violating NOT NULL) instead
|
||||
// of falling through to the schema default '{}'.
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
account_number: '1510',
|
||||
@@ -436,6 +481,7 @@ export async function POST(request: Request) {
|
||||
debit_amount: 18750,
|
||||
credit_amount: 0,
|
||||
sort_order: 0,
|
||||
dimensions: {},
|
||||
},
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
@@ -444,6 +490,8 @@ export async function POST(request: Request) {
|
||||
debit_amount: 0,
|
||||
credit_amount: 15000,
|
||||
sort_order: 1,
|
||||
dimensions: revenueDims,
|
||||
...lineDimensionColumns(revenueDims),
|
||||
},
|
||||
{
|
||||
journal_entry_id: je1.id,
|
||||
@@ -452,6 +500,7 @@ export async function POST(request: Request) {
|
||||
debit_amount: 0,
|
||||
credit_amount: 3750,
|
||||
sort_order: 2,
|
||||
dimensions: {},
|
||||
},
|
||||
// JE2: Invoice payment — Debit Bank, Credit AR
|
||||
{
|
||||
@@ -461,6 +510,7 @@ export async function POST(request: Request) {
|
||||
debit_amount: 18750,
|
||||
credit_amount: 0,
|
||||
sort_order: 0,
|
||||
dimensions: {},
|
||||
},
|
||||
{
|
||||
journal_entry_id: je2.id,
|
||||
@@ -469,6 +519,7 @@ export async function POST(request: Request) {
|
||||
debit_amount: 0,
|
||||
credit_amount: 18750,
|
||||
sort_order: 1,
|
||||
dimensions: {},
|
||||
},
|
||||
])
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/dimensions/{id}/values
|
||||
*
|
||||
* Create a dimension value (SIE #OBJEKT). Idempotent (mandatory
|
||||
* Idempotency-Key), dry-runnable. Codes follow the strict Fortnox format
|
||||
* (^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$) for user-created values; the DB CHECK is
|
||||
* looser by design so legacy free-text codes survive imports. `code` is
|
||||
* immutable after creation.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { created } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateDimensionValueSchema } from '@/lib/api/schemas'
|
||||
|
||||
const DimensionValueCreated = z.object({
|
||||
id: z.string().uuid().nullable(),
|
||||
dimension_id: z.string().uuid(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
is_active: z.boolean(),
|
||||
start_date: z.string().nullable(),
|
||||
end_date: z.string().nullable(),
|
||||
created_at: z.string().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'dimensions.values.create',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/dimensions/:id/values',
|
||||
summary: 'Create a dimension value (kostnadsställe/projekt code).',
|
||||
description:
|
||||
'Registers a new value (SIE #OBJEKT) under a dimension — e.g. a new project code under dimension 6. Requires Idempotency-Key (UUID). Supports ?dry_run=true to validate the code format without committing. The `:id` path segment is the dimension row id (from GET …/dimensions), not the sie_dim_no. Duplicate codes within the dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE.',
|
||||
useWhen:
|
||||
'A voucher or invoice references a cost centre / project code that does not exist yet and the user has confirmed it should be created.',
|
||||
doNotUseFor:
|
||||
'Renaming or archiving an existing value (dashboard register in v1). Tagging lines — pass the dimensions map on the journal-entry line instead.',
|
||||
pitfalls: [
|
||||
'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.',
|
||||
'The :id segment is the dimension UUID, not the SIE dimension number.',
|
||||
'Codes are limited to the strict Fortnox charset (A–Ö, digits, _, +, -; max 20 chars) even though historical imported codes may be looser.',
|
||||
'code is immutable after creation — there is no rename in v1; create the correct code and archive the wrong one.',
|
||||
],
|
||||
example: {
|
||||
request: { code: 'P001', name: 'Villa Almgren tak' },
|
||||
response: {
|
||||
data: {
|
||||
id: '0e9c…',
|
||||
dimension_id: 'a8f1…',
|
||||
code: 'P001',
|
||||
name: 'Villa Almgren tak',
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
created_at: '2026-07-02T12:00:00Z',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'bookkeeping:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: CreateDimensionValueSchema },
|
||||
response: { success: dataEnvelope(DimensionValueCreated) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'dimensions.values.create',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
|
||||
if (!z.string().uuid().safeParse(id).success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Dimension id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = CreateDimensionValueSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// The dimension must exist and belong to the company in the URL.
|
||||
const { data: dimension, error: dimError } = await ctx.supabase
|
||||
.from('dimensions')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (dimError) {
|
||||
return v1ErrorResponse(dimError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!dimension) {
|
||||
return v1ErrorResponseFromCode('DIMENSION_NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { dimension_id: id },
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(
|
||||
{
|
||||
id: null,
|
||||
dimension_id: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
is_active: true,
|
||||
start_date: body.start_date ?? null,
|
||||
end_date: body.end_date ?? null,
|
||||
created_at: null,
|
||||
},
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('dimension_values')
|
||||
.insert({
|
||||
company_id: ctx.companyId!,
|
||||
dimension_id: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
start_date: body.start_date ?? null,
|
||||
end_date: body.end_date ?? null,
|
||||
})
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date, created_at')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return v1ErrorResponseFromCode('DIMENSION_VALUE_DUPLICATE_CODE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { code: body.code },
|
||||
})
|
||||
}
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
return created(data, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Integration tests for the v1 dimensions surface (dimensions PR2):
|
||||
* GET /api/v1/companies/:companyId/dimensions
|
||||
* POST /api/v1/companies/:companyId/dimensions/:id/values
|
||||
*
|
||||
* Mirrors the suppliers/accounts test pattern: a Proxy-backed Supabase mock
|
||||
* returns whatever the route awaits, keyed by table name (plus an `rpc` key
|
||||
* for ensure_company_dimensions).
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required')
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() }
|
||||
})
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listDimensions } from '../route'
|
||||
import { POST as createValue } from '../[id]/values/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-table queue mock: TableResp[] consumes one entry per await, then sticks
|
||||
* on the last entry. The special key `rpc` answers `supabase.rpc(...)` calls.
|
||||
*/
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (key: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(key)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(key)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
from: vi.fn((table: string) => buildChain(table)),
|
||||
rpc: vi.fn(() => buildChain('rpc')),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const DIMENSION_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'c1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const SAMPLE_DIMS = [
|
||||
{
|
||||
id: DIMENSION_ID,
|
||||
sie_dim_no: 1,
|
||||
name: 'Kostnadsställe',
|
||||
resets_annually: true,
|
||||
is_system: true,
|
||||
is_active: true,
|
||||
sort_order: 10,
|
||||
},
|
||||
{
|
||||
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
sie_dim_no: 6,
|
||||
name: 'Projekt',
|
||||
resets_annually: false,
|
||||
is_system: true,
|
||||
is_active: true,
|
||||
sort_order: 20,
|
||||
},
|
||||
]
|
||||
|
||||
const SAMPLE_VALUE = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
dimension_id: DIMENSION_ID,
|
||||
code: 'BUTIK',
|
||||
name: 'Butiken',
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['reports:read', 'bookkeeping:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/dimensions', () => {
|
||||
it('ensures system dims and returns the registry with nested values', async () => {
|
||||
const client = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
rpc: { data: null, error: null },
|
||||
dimensions: { data: SAMPLE_DIMS, error: null },
|
||||
dimension_values: { data: [SAMPLE_VALUE], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(client)
|
||||
|
||||
const res = await listDimensions(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/dimensions`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(client.rpc).toHaveBeenCalledWith('ensure_company_dimensions', {
|
||||
p_company_id: COMPANY_ID,
|
||||
})
|
||||
const body = await res.json()
|
||||
expect(body.data.dimensions).toHaveLength(2)
|
||||
expect(body.data.dimensions[0].sie_dim_no).toBe(1)
|
||||
expect(body.data.dimensions[0].values).toEqual([
|
||||
{
|
||||
id: SAMPLE_VALUE.id,
|
||||
code: 'BUTIK',
|
||||
name: 'Butiken',
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
},
|
||||
])
|
||||
expect(body.data.dimensions[1].values).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects keys without reports:read scope (403)', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await listDimensions(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/dimensions`),
|
||||
{ params: Promise.resolve({ companyId: COMPANY_ID }) },
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/dimensions/:id/values', () => {
|
||||
const url = `https://x.test/api/v1/companies/${COMPANY_ID}/dimensions/${DIMENSION_ID}/values`
|
||||
const detailParams = { params: Promise.resolve({ companyId: COMPANY_ID, id: DIMENSION_ID }) }
|
||||
|
||||
it('creates a value (201, happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
dimensions: { data: { id: DIMENSION_ID }, error: null },
|
||||
dimension_values: { data: { ...SAMPLE_VALUE, created_at: '2026-07-02T12:00:00Z' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createValue(
|
||||
makeRequest(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }),
|
||||
}),
|
||||
detailParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.code).toBe('BUTIK')
|
||||
})
|
||||
|
||||
it('returns 400 when Idempotency-Key is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const req = new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test' },
|
||||
body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }),
|
||||
})
|
||||
const res = await createValue(req, detailParams)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a non-Fortnox code with 400 VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createValue(
|
||||
makeRequest(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'BAD CODE!', name: 'x' }),
|
||||
}),
|
||||
detailParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 404 DIMENSION_NOT_FOUND for a dimension outside the company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
dimensions: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createValue(
|
||||
makeRequest(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }),
|
||||
}),
|
||||
detailParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('DIMENSION_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 409 DIMENSION_VALUE_DUPLICATE_CODE on a 23505', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
dimensions: { data: { id: DIMENSION_ID }, error: null },
|
||||
dimension_values: { data: null, error: { code: '23505', message: 'duplicate' } },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createValue(
|
||||
makeRequest(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'BUTIK', name: 'Butiken' }),
|
||||
}),
|
||||
detailParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('DIMENSION_VALUE_DUPLICATE_CODE')
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without inserting when ?dry_run=true', async () => {
|
||||
const fromSpy = vi.fn()
|
||||
mockServiceClient.mockReturnValue({
|
||||
from: (table: string) => {
|
||||
fromSpy(table)
|
||||
return new Proxy({}, {
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
const data =
|
||||
table === 'company_members'
|
||||
? { company_id: COMPANY_ID, role: 'owner' }
|
||||
: table === 'dimensions'
|
||||
? { id: DIMENSION_ID }
|
||||
: null
|
||||
return (resolve: (v: unknown) => void) => resolve({ data, error: null })
|
||||
}
|
||||
return () => new Proxy({}, this!)
|
||||
},
|
||||
})
|
||||
},
|
||||
rpc: vi.fn(),
|
||||
})
|
||||
|
||||
const res = await createValue(
|
||||
makeRequest(`${url}?dry_run=true`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: 'P001', name: 'Villa Almgren tak' }),
|
||||
}),
|
||||
detailParams,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(fromSpy).not.toHaveBeenCalledWith('dimension_values')
|
||||
const body = await res.json()
|
||||
expect(body.data.dry_run).toBe(true)
|
||||
expect(body.data.preview.code).toBe('P001')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/dimensions
|
||||
*
|
||||
* List the dimension registry (SIE #DIM) with nested values (#OBJEKT).
|
||||
* Ensures the system dims (1 = Kostnadsställe, 6 = Projekt) exist via the
|
||||
* ensure_company_dimensions RPC before reading — lazy seeding, so the list is
|
||||
* never empty even for companies that have not touched dimensions.
|
||||
*/
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse } from '@/lib/api/v1/errors'
|
||||
|
||||
const DimensionValue = z.object({
|
||||
id: z.string().uuid(),
|
||||
code: z.string(),
|
||||
name: z.string(),
|
||||
is_active: z.boolean(),
|
||||
start_date: z.string().nullable(),
|
||||
end_date: z.string().nullable(),
|
||||
})
|
||||
|
||||
const Dimension = z.object({
|
||||
id: z.string().uuid(),
|
||||
sie_dim_no: z.number().int().min(1),
|
||||
name: z.string(),
|
||||
resets_annually: z.boolean(),
|
||||
is_system: z.boolean(),
|
||||
is_active: z.boolean(),
|
||||
sort_order: z.number().int(),
|
||||
values: z.array(DimensionValue),
|
||||
})
|
||||
|
||||
const DimensionsResponse = dataEnvelope(z.object({ dimensions: z.array(Dimension) }))
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'dimensions.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/dimensions',
|
||||
summary: 'List dimensions (kostnadsställe/projekt) with their values.',
|
||||
description:
|
||||
'Returns the company\'s dimension registry — SIE #DIM entries keyed by sie_dim_no (1 = Kostnadsställe, 6 = Projekt; both always exist) — with the registered values (#OBJEKT) nested under each dimension. Dimensions are ordered by sort_order, values by code. Line-level tags on journal entries reference these values as {"<sie_dim_no>":"<code>"} in the `dimensions` map.',
|
||||
useWhen:
|
||||
'You need the valid dimension value codes before tagging journal-entry lines with a cost centre or project, or you are rendering a dimension picker.',
|
||||
doNotUseFor:
|
||||
'Filtering reports (pass the dimension filter to the report endpoints once available) or reading which lines carry a tag (read the journal entries themselves).',
|
||||
pitfalls: [
|
||||
'Dimension value codes are STRINGS and case-sensitive — "P001", not 1.',
|
||||
'sie_dim_no is the key used in journal_entry_lines.dimensions, NOT the dimension row id.',
|
||||
'is_active=false values are historical (archived) — do not tag new lines with them.',
|
||||
'resets_annually=true (dim 1) means balances reset each fiscal year; dim 6 (projekt) accumulates across years.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
dimensions: [
|
||||
{
|
||||
id: '0e9c…',
|
||||
sie_dim_no: 1,
|
||||
name: 'Kostnadsställe',
|
||||
resets_annually: true,
|
||||
is_system: true,
|
||||
is_active: true,
|
||||
sort_order: 10,
|
||||
values: [
|
||||
{
|
||||
id: 'a8f1…',
|
||||
code: 'BUTIK',
|
||||
name: 'Butiken',
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'reports:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: DimensionsResponse },
|
||||
})
|
||||
|
||||
interface DimensionValueRow {
|
||||
id: string
|
||||
dimension_id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'dimensions.list',
|
||||
async (_request, ctx) => {
|
||||
const { error: ensureError } = await ctx.supabase.rpc('ensure_company_dimensions', {
|
||||
p_company_id: ctx.companyId!,
|
||||
})
|
||||
if (ensureError) {
|
||||
return v1ErrorResponse(ensureError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { data: dims, error: dimsError } = await ctx.supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, name, resets_annually, is_system, is_active, sort_order')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.order('sort_order', { ascending: true })
|
||||
.order('sie_dim_no', { ascending: true })
|
||||
if (dimsError) {
|
||||
return v1ErrorResponse(dimsError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { data: values, error: valuesError } = await ctx.supabase
|
||||
.from('dimension_values')
|
||||
.select('id, dimension_id, code, name, is_active, start_date, end_date')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.order('code', { ascending: true })
|
||||
if (valuesError) {
|
||||
return v1ErrorResponse(valuesError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const valuesByDimension = new Map<string, Omit<DimensionValueRow, 'dimension_id'>[]>()
|
||||
for (const v of ((values ?? []) as DimensionValueRow[])) {
|
||||
const bucket = valuesByDimension.get(v.dimension_id) ?? []
|
||||
bucket.push({
|
||||
id: v.id,
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
is_active: v.is_active,
|
||||
start_date: v.start_date,
|
||||
end_date: v.end_date,
|
||||
})
|
||||
valuesByDimension.set(v.dimension_id, bucket)
|
||||
}
|
||||
|
||||
const dimensions = ((dims ?? []) as Array<Record<string, unknown> & { id: string }>).map(
|
||||
(d) => ({ ...d, values: valuesByDimension.get(d.id) ?? [] }),
|
||||
)
|
||||
|
||||
return ok({ dimensions }, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
HandCoins,
|
||||
Package,
|
||||
Tag,
|
||||
Tags,
|
||||
ChevronsUpDown,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
@@ -68,6 +69,10 @@ interface DashboardNavProps {
|
||||
// pays_salaries). Drives visibility of the payroll (Personal) section for
|
||||
// non-aktiebolag — notably an enskild firma that hires staff. See #782.
|
||||
paysSalaries?: boolean
|
||||
// Whether the dimensions register (company_settings.dimensions_enabled) is
|
||||
// switched on. Drives visibility of the Kostnadsställen & projekt row —
|
||||
// same mechanism as paysSalaries: fetched by the dashboard layout.
|
||||
dimensionsEnabled?: boolean
|
||||
uncategorizedTransactionCount?: number
|
||||
pendingOperationsCount?: number
|
||||
isSandbox?: boolean
|
||||
@@ -94,6 +99,7 @@ type NavLabelKey =
|
||||
| 'transactions'
|
||||
| 'bookkeeping'
|
||||
| 'chart_of_accounts'
|
||||
| 'dimensions'
|
||||
| 'assets'
|
||||
| 'reports'
|
||||
| 'import'
|
||||
@@ -123,6 +129,10 @@ interface NavItem {
|
||||
// behaviour) plus any company that has registered as an employer via
|
||||
// company_settings.pays_salaries (e.g. an enskild firma with staff). #782
|
||||
employerOnly?: boolean
|
||||
// Dimension surfaces — visible only when the company has opted in via
|
||||
// company_settings.dimensions_enabled (UI-visibility gate only; the pages
|
||||
// and APIs work regardless — dimensions plan §2).
|
||||
requiresDimensions?: boolean
|
||||
hidden?: boolean
|
||||
comingSoon?: boolean
|
||||
devBadge?: boolean
|
||||
@@ -147,6 +157,7 @@ const navItems: NavItem[] = [
|
||||
{ href: '/kpi', labelKey: 'kpi', icon: TrendingUp, group: 'redovisning' },
|
||||
{ href: '/bookkeeping', labelKey: 'bookkeeping', icon: BookOpen, group: 'redovisning' },
|
||||
{ href: '/chart-of-accounts', labelKey: 'chart_of_accounts', icon: ListTree, group: 'redovisning' },
|
||||
{ href: '/dimensions', labelKey: 'dimensions', icon: Tags, group: 'redovisning', requiresDimensions: true },
|
||||
{ href: '/assets', labelKey: 'assets', icon: Package, group: 'redovisning' },
|
||||
{ href: '/reports', labelKey: 'reports', icon: BarChart3, group: 'redovisning' },
|
||||
{ href: '/import', labelKey: 'import', icon: Upload, group: 'redovisning' },
|
||||
@@ -185,7 +196,7 @@ function accountInitial(name: string | null, email: string | null): string {
|
||||
return '?'
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
@@ -351,6 +362,9 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// Payroll (employerOnly) is hidden until the company is an employer — an
|
||||
// aktiebolag, or any entity that has flagged pays_salaries. #782
|
||||
if (item.employerOnly && !isEmployer) return false
|
||||
// Dimension surfaces are hidden until the company opts in via the
|
||||
// bookkeeping settings toggle (company_settings.dimensions_enabled).
|
||||
if (item.requiresDimensions && !dimensionsEnabled) return false
|
||||
// Hide the Assistent (/chat) tab until the agent is built — mirrors the
|
||||
// floating AgentTrigger and avoids a nav entry that only bounces to the
|
||||
// home checklist (chat/layout redirects unverified users to /).
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
DIMENSION_CODE_PATTERN,
|
||||
fetchDimensions,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
interface DimensionComboboxProps {
|
||||
/** SIE dimension number as a string ('1' = kostnadsställe, '6' = projekt). */
|
||||
sieDimNo: string
|
||||
/** Selected object code, or null when the line carries no value for this dim. */
|
||||
value: string | null
|
||||
onChange: (code: string | null) => void
|
||||
disabled?: boolean
|
||||
/** Extra classes merged into the trigger Input (callers pass `h-8` for dense rows). */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable picker for dimension values — sibling of
|
||||
* components/bookkeeping/AccountCombobox.tsx and deliberately mirrors its
|
||||
* interaction model: plain Input trigger, keyboard-first dropdown
|
||||
* (arrows/Enter/Escape), close-on-outside-click, and an inline "Skapa ny…"
|
||||
* affordance that creates the value via POST /api/dimensions/[id]/values and
|
||||
* selects it.
|
||||
*
|
||||
* Fetches the registry lazily on first open and filters client-side (value
|
||||
* lists are small). Only active values are offered — archived codes stay
|
||||
* pickable in history but are never suggested.
|
||||
*
|
||||
* Strings are hardcoded Swedish per the AccountCombobox convention: the
|
||||
* component mounts on the voucher editor (PR3), a stays-Swedish surface per
|
||||
* .claude/rules/i18n.md.
|
||||
*/
|
||||
export default function DimensionCombobox({
|
||||
sieDimNo,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: DimensionComboboxProps) {
|
||||
const [search, setSearch] = useState(value ?? '')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0)
|
||||
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle')
|
||||
const [dimensionId, setDimensionId] = useState<string | null>(null)
|
||||
const [values, setValues] = useState<DimensionValueDto[]>([])
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Refs mirroring the committed `value` prop and the fetched values. The
|
||||
// blur timeout below runs 150ms after render, so reading `value`/`values`
|
||||
// directly would act on a stale snapshot — a selection landing during that
|
||||
// window (updating the prop via onChange) must win over the revert.
|
||||
const committedRef = useRef(value)
|
||||
const valuesRef = useRef(values)
|
||||
|
||||
// Sync external value changes into the search field
|
||||
useEffect(() => {
|
||||
committedRef.current = value
|
||||
setSearch(value ?? '')
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
valuesRef.current = values
|
||||
}, [values])
|
||||
|
||||
const loadValues = useCallback(async () => {
|
||||
setLoadState('loading')
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const dim = dims.find((d) => String(d.sie_dim_no) === sieDimNo)
|
||||
setDimensionId(dim?.id ?? null)
|
||||
setValues(dim?.values.filter((v) => v.is_active) ?? [])
|
||||
setLoadState('loaded')
|
||||
} catch {
|
||||
setLoadState('error')
|
||||
}
|
||||
}, [sieDimNo])
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
setIsOpen(true)
|
||||
setCreateError(null)
|
||||
if (loadState === 'idle') void loadValues()
|
||||
}, [loadState, loadValues])
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
const term = search.trim().toLowerCase()
|
||||
if (!term) return values
|
||||
return values.filter(
|
||||
(v) =>
|
||||
v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term),
|
||||
)
|
||||
}, [values, search])
|
||||
|
||||
// Inline create is offered when the typed text is a valid new code.
|
||||
const createCandidate = useMemo(() => {
|
||||
const term = search.trim()
|
||||
if (!term || !dimensionId) return null
|
||||
if (!DIMENSION_CODE_PATTERN.test(term)) return null
|
||||
if (values.some((v) => v.code.toLowerCase() === term.toLowerCase())) return null
|
||||
return term
|
||||
}, [search, values, dimensionId])
|
||||
|
||||
// Keyboard list: matching values first, the create affordance last.
|
||||
const optionCount = filteredValues.length + (createCandidate ? 1 : 0)
|
||||
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(0)
|
||||
}, [filteredValues, createCandidate])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !listRef.current) return
|
||||
const highlighted = listRef.current.querySelector('[data-highlighted="true"]')
|
||||
if (highlighted) highlighted.scrollIntoView({ block: 'nearest' })
|
||||
}, [highlightedIndex, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent | TouchEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('touchstart', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('touchstart', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectValue = useCallback(
|
||||
(code: string) => {
|
||||
onChange(code)
|
||||
setSearch(code)
|
||||
setIsOpen(false)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const createValue = useCallback(
|
||||
async (code: string) => {
|
||||
if (!dimensionId || isCreating) return
|
||||
setIsCreating(true)
|
||||
setCreateError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/dimensions/${dimensionId}/values`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, name: code }),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
setCreateError(getErrorMessage(json, { locale: 'sv' }))
|
||||
return
|
||||
}
|
||||
const created: DimensionValueDto = json?.data ?? {
|
||||
id: code,
|
||||
code,
|
||||
name: code,
|
||||
is_active: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
}
|
||||
setValues((prev) => [...prev, created].sort((a, b) => a.code.localeCompare(b.code, 'sv')))
|
||||
selectValue(created.code)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
},
|
||||
[dimensionId, isCreating, selectValue],
|
||||
)
|
||||
|
||||
const activateOption = useCallback(
|
||||
(index: number) => {
|
||||
if (index < filteredValues.length) {
|
||||
selectValue(filteredValues[index].code)
|
||||
} else if (createCandidate) {
|
||||
void createValue(createCandidate)
|
||||
}
|
||||
},
|
||||
[filteredValues, createCandidate, selectValue, createValue],
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
openDropdown()
|
||||
e.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.min(prev + 1, optionCount - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (optionCount > 0) activateOption(highlightedIndex)
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
setIsOpen(false)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
setCreateError(null)
|
||||
if (!isOpen) openDropdown()
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsOpen(false)
|
||||
// Small delay so a dropdown mousedown fires first (same trick as
|
||||
// AccountCombobox — option clicks preventDefault, so they never blur).
|
||||
// An emptied field clears the dimension; anything that isn't a known
|
||||
// code reverts to the committed value. Committed value and values are
|
||||
// read through refs so a selection that lands during the 150ms window
|
||||
// wins over the revert (the closure's render snapshot would be stale).
|
||||
const snapshot = search
|
||||
setTimeout(() => {
|
||||
const committed = committedRef.current
|
||||
const trimmed = snapshot.trim()
|
||||
if (!trimmed) {
|
||||
setSearch('')
|
||||
if (committed !== null) onChange(null)
|
||||
return
|
||||
}
|
||||
if (trimmed !== committed && !valuesRef.current.some((v) => v.code === trimmed)) {
|
||||
setSearch(committed ?? '')
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={handleInputChange}
|
||||
onFocus={openDropdown}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Sök värde…"
|
||||
disabled={disabled}
|
||||
className={`font-mono ${className ?? ''}`.trim()}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && !disabled && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 top-full left-0 mt-1 min-w-[16rem] w-[max(100%,20rem)] max-h-[300px] overflow-y-auto rounded-md border border-input bg-card shadow-md"
|
||||
>
|
||||
{loadState === 'loading' && (
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Laddar…
|
||||
</div>
|
||||
)}
|
||||
{loadState === 'error' && (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">
|
||||
Kunde inte hämta värden.
|
||||
</p>
|
||||
)}
|
||||
{loadState === 'loaded' && optionCount === 0 && (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">
|
||||
Hittade inget värde som matchar.
|
||||
</p>
|
||||
)}
|
||||
{loadState === 'loaded' &&
|
||||
filteredValues.map((item, index) => {
|
||||
const isHighlighted = index === highlightedIndex
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-highlighted={isHighlighted}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
|
||||
isHighlighted ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
selectValue(item.code)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<span className="font-mono shrink-0">{item.code}</span>
|
||||
<span className="flex-1 min-w-0 break-words text-muted-foreground">
|
||||
{item.name !== item.code ? item.name : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{loadState === 'loaded' && createCandidate && (
|
||||
<button
|
||||
type="button"
|
||||
data-highlighted={highlightedIndex === filteredValues.length}
|
||||
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-center gap-2 border-t border-input ${
|
||||
highlightedIndex === filteredValues.length
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
void createValue(createCandidate)
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(filteredValues.length)}
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">Skapa ny "{createCandidate}"</span>
|
||||
</button>
|
||||
)}
|
||||
{createError && (
|
||||
<p className="px-2 py-1.5 text-xs text-destructive border-t border-input">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
DIMENSION_CODE_PATTERN,
|
||||
PROJECT_DIM_NO,
|
||||
type DimensionDto,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
export interface DimensionValueFormInput {
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
interface DimensionValueFormProps {
|
||||
/** The dimension the value belongs to (drives the Projekt date fields). */
|
||||
dimension: DimensionDto
|
||||
/** When set, the form edits this value (code becomes immutable). */
|
||||
value?: DimensionValueDto | null
|
||||
isSaving?: boolean
|
||||
onSubmit: (input: DimensionValueFormInput) => void | Promise<void>
|
||||
/** Rendered only when editing. The caller performs the DELETE and surfaces
|
||||
* the retention-trigger error ("…arkivera det istället") as a toast. */
|
||||
onDelete?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit form for a dimension value (#OBJEKT), hosted in the
|
||||
* DimensionsManager dialog. Code is immutable in v1 — the field is disabled
|
||||
* when editing. Start/end dates appear only for Projekt (dim 6), matching the
|
||||
* SIE model where projects span a date range while cost centres do not.
|
||||
*/
|
||||
export default function DimensionValueForm({
|
||||
dimension,
|
||||
value,
|
||||
isSaving = false,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}: DimensionValueFormProps) {
|
||||
const t = useTranslations('dimensions')
|
||||
const isEditing = Boolean(value)
|
||||
const isProject = dimension.sie_dim_no === PROJECT_DIM_NO
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
|
||||
const schema = useMemo(
|
||||
() =>
|
||||
z
|
||||
.object({
|
||||
// Legacy/backfilled codes can be looser than the strict format, so
|
||||
// the pattern is only enforced when the code is user-typed (create).
|
||||
code: isEditing
|
||||
? z.string()
|
||||
: z.string().regex(DIMENSION_CODE_PATTERN, t('form_code_invalid')),
|
||||
name: z.string().min(1, t('form_name_invalid')).max(120, t('form_name_invalid')),
|
||||
is_active: z.boolean(),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!data.start_date || !data.end_date || data.end_date >= data.start_date,
|
||||
{ message: t('form_date_order_invalid'), path: ['end_date'] },
|
||||
),
|
||||
[isEditing, t],
|
||||
)
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
code: value?.code ?? '',
|
||||
name: value?.name ?? '',
|
||||
is_active: value?.is_active ?? true,
|
||||
start_date: value?.start_date ?? '',
|
||||
end_date: value?.end_date ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
function submit(data: FormData) {
|
||||
return onSubmit({
|
||||
code: data.code.trim(),
|
||||
name: data.name.trim(),
|
||||
is_active: data.is_active,
|
||||
start_date: isProject && data.start_date ? data.start_date : null,
|
||||
end_date: isProject && data.end_date ? data.end_date : null,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(submit)} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-code">{t('form_code_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-code"
|
||||
{...register('code')}
|
||||
disabled={isEditing}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
autoFocus={!isEditing}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEditing ? t('form_code_immutable_help') : t('form_code_help')}
|
||||
</p>
|
||||
{errors.code && (
|
||||
<p className="text-xs text-destructive">{errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-name">{t('form_name_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-name"
|
||||
{...register('name')}
|
||||
autoComplete="off"
|
||||
autoFocus={isEditing}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProject && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-start">{t('form_start_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-start"
|
||||
type="date"
|
||||
{...register('start_date')}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dimension-value-end">{t('form_end_label')}</Label>
|
||||
<Input
|
||||
id="dimension-value-end"
|
||||
type="date"
|
||||
{...register('end_date')}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
{errors.end_date && (
|
||||
<p className="text-xs text-destructive">{errors.end_date.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dimension-value-active">{t('form_active_label')}</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('form_active_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Controller
|
||||
name="is_active"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="dimension-value-active"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
{isEditing && onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t('form_delete')}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isEditing ? t('form_save') : t('form_create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{onDelete && (
|
||||
<DestructiveConfirmDialog
|
||||
open={confirmDeleteOpen}
|
||||
onOpenChange={setConfirmDeleteOpen}
|
||||
title={t('delete_confirm_title')}
|
||||
description={t('delete_confirm_description', { code: value?.code ?? '' })}
|
||||
confirmLabel={t('delete_confirm_label')}
|
||||
cancelLabel={t('delete_cancel_label')}
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Lock,
|
||||
Tags,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react'
|
||||
import DimensionValueForm, {
|
||||
type DimensionValueFormInput,
|
||||
} from '@/components/dimensions/DimensionValueForm'
|
||||
import {
|
||||
fetchDimensions,
|
||||
PROJECT_DIM_NO,
|
||||
type DimensionDto,
|
||||
type DimensionValueDto,
|
||||
} from '@/components/dimensions/types'
|
||||
|
||||
type SortColumn = 'code' | 'name' | 'status' | 'start_date' | 'end_date'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
type DialogState =
|
||||
| { mode: 'create' }
|
||||
| { mode: 'edit'; value: DimensionValueDto }
|
||||
| null
|
||||
|
||||
function compareStrings(a: string, b: string): number {
|
||||
return a.localeCompare(b, 'sv', { sensitivity: 'base' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register for dimension values (kostnadsställen & projekt) — the
|
||||
* customers-page register recipe hosted behind one segmented tab per registry
|
||||
* dimension. Archive rides the edit form's aktiv switch (PATCH is_active);
|
||||
* delete lives only in the edit dialog and surfaces the DB retention
|
||||
* trigger's Swedish "…arkivera det istället" message when the value is
|
||||
* referenced by posted lines.
|
||||
*/
|
||||
export default function DimensionsManager() {
|
||||
const t = useTranslations('dimensions')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { toast } = useToast()
|
||||
const { canWrite } = useCanWrite()
|
||||
|
||||
const [dimensions, setDimensions] = useState<DimensionDto[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [activeDimId, setActiveDimId] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('code')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
const [dialog, setDialog] = useState<DialogState>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const loadDimensions = useCallback(
|
||||
async (showSpinner: boolean) => {
|
||||
if (showSpinner) setIsLoading(true)
|
||||
try {
|
||||
const dims = await fetchDimensions()
|
||||
const sorted = [...dims].sort(
|
||||
(a, b) => a.sort_order - b.sort_order || a.sie_dim_no - b.sie_dim_no,
|
||||
)
|
||||
setDimensions(sorted)
|
||||
setLoadFailed(false)
|
||||
setActiveDimId((prev) =>
|
||||
prev && sorted.some((d) => d.id === prev) ? prev : (sorted[0]?.id ?? null),
|
||||
)
|
||||
} catch (err) {
|
||||
setLoadFailed(true)
|
||||
toast({
|
||||
title: t('load_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[toast, t, errorLocale],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadDimensions(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const activeDim = useMemo(
|
||||
() => dimensions.find((d) => d.id === activeDimId) ?? null,
|
||||
[dimensions, activeDimId],
|
||||
)
|
||||
const isProjectTab = activeDim?.sie_dim_no === PROJECT_DIM_NO
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
if (!activeDim) return []
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
if (!term) return activeDim.values
|
||||
return activeDim.values.filter(
|
||||
(v) =>
|
||||
v.code.toLowerCase().includes(term) || v.name.toLowerCase().includes(term),
|
||||
)
|
||||
}, [activeDim, searchTerm])
|
||||
|
||||
const sortedValues = useMemo(() => {
|
||||
const arr = [...filteredValues]
|
||||
arr.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortColumn) {
|
||||
case 'code':
|
||||
cmp = compareStrings(a.code, b.code)
|
||||
break
|
||||
case 'name':
|
||||
cmp = compareStrings(a.name, b.name)
|
||||
break
|
||||
case 'status':
|
||||
cmp = Number(b.is_active) - Number(a.is_active) || compareStrings(a.code, b.code)
|
||||
break
|
||||
case 'start_date':
|
||||
cmp = compareStrings(a.start_date ?? '', b.start_date ?? '')
|
||||
break
|
||||
case 'end_date':
|
||||
cmp = compareStrings(a.end_date ?? '', b.end_date ?? '')
|
||||
break
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return arr
|
||||
}, [filteredValues, sortColumn, sortDir])
|
||||
|
||||
const updateSort = useCallback(
|
||||
(column: SortColumn) => {
|
||||
if (column === sortColumn) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
},
|
||||
[sortColumn],
|
||||
)
|
||||
|
||||
async function handleSubmitValue(input: DimensionValueFormInput) {
|
||||
if (!activeDim || !dialog) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (dialog.mode === 'edit') {
|
||||
const res = await fetch(
|
||||
`/api/dimensions/${activeDim.id}/values/${dialog.value.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: input.name,
|
||||
is_active: input.is_active,
|
||||
start_date: input.start_date,
|
||||
end_date: input.end_date,
|
||||
}),
|
||||
},
|
||||
)
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw json ?? new Error()
|
||||
toast({ title: t('updated_title') })
|
||||
} else {
|
||||
// "Create as archived" rides the create contract's is_active field —
|
||||
// one atomic POST, no follow-up PATCH.
|
||||
const body: Record<string, unknown> = {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
is_active: input.is_active,
|
||||
}
|
||||
if (input.start_date) body.start_date = input.start_date
|
||||
if (input.end_date) body.end_date = input.end_date
|
||||
const res = await fetch(`/api/dimensions/${activeDim.id}/values`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) throw json ?? new Error()
|
||||
toast({
|
||||
title: t('created_title'),
|
||||
description: t('created_description', { code: input.code }),
|
||||
})
|
||||
}
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('save_failed_title'),
|
||||
description: getErrorMessage(err, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteValue() {
|
||||
if (!activeDim || dialog?.mode !== 'edit') return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/dimensions/${activeDim.id}/values/${dialog.value.id}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
// Values referenced by posted lines cannot be deleted — the DB
|
||||
// retention trigger's Swedish message ("…arkivera det istället")
|
||||
// rides the error envelope; surface it verbatim.
|
||||
toast({
|
||||
title: t('delete_failed_title'),
|
||||
description: getErrorMessage(json, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({ title: t('deleted_title') })
|
||||
setDialog(null)
|
||||
await loadDimensions(false)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
column,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
column: SortColumn
|
||||
label: string
|
||||
className?: string
|
||||
}) {
|
||||
const isActive = sortColumn === column
|
||||
const Icon = isActive ? (sortDir === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateSort(column)}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{label}
|
||||
<Icon className="h-3 w-3 opacity-70" aria-hidden="true" />
|
||||
</button>
|
||||
</TableHead>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStatusBadge(value: DimensionValueDto) {
|
||||
return value.is_active ? (
|
||||
<Badge variant="success">{t('status_active')}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t('status_archived')}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadFailed && dimensions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
actionLabel={t('retry')}
|
||||
onAction={() => void loadDimensions(true)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Segmented tabs — one per registry dimension (1 Kostnadsställe, 6 Projekt) */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Tabs
|
||||
value={activeDimId ?? undefined}
|
||||
onValueChange={(id) => {
|
||||
setActiveDimId(id)
|
||||
setSearchTerm('')
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
{dimensions.map((dim) => (
|
||||
<TabsTrigger key={dim.id} value={dim.id}>
|
||||
{dim.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
disabled={!canWrite}
|
||||
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
|
||||
onClick={() => setDialog({ mode: 'create' })}
|
||||
>
|
||||
{canWrite ? <Plus className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
|
||||
{t('new_value')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('search_placeholder')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Value list */}
|
||||
{sortedValues.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{searchTerm ? (
|
||||
<EmptyState
|
||||
icon={Search}
|
||||
title={t('no_search_results_title')}
|
||||
description={t('no_search_results_description', { term: searchTerm })}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Tags}
|
||||
title={t('empty_title')}
|
||||
description={t('empty_description', { dimension: activeDim?.name ?? '' })}
|
||||
actionLabel={canWrite ? t('new_value') : undefined}
|
||||
onAction={canWrite ? () => setDialog({ mode: 'create' }) : undefined}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHeader column="code" label={t('col_code')} />
|
||||
<SortableHeader column="name" label={t('col_name')} />
|
||||
<SortableHeader column="status" label={t('col_status')} />
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<SortableHeader column="start_date" label={t('col_start')} />
|
||||
<SortableHeader column="end_date" label={t('col_end')} />
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedValues.map((value) => (
|
||||
<TableRow
|
||||
key={value.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<TableCell className="font-mono">{value.code}</TableCell>
|
||||
<TableCell className="font-medium">{value.name}</TableCell>
|
||||
<TableCell>{renderStatusBadge(value)}</TableCell>
|
||||
{isProjectTab && (
|
||||
<>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.start_date ? formatDate(value.start_date) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-muted-foreground">
|
||||
{value.end_date ? formatDate(value.end_date) : '—'}
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card list */}
|
||||
<div className="grid gap-4 md:hidden">
|
||||
{sortedValues.map((value) => (
|
||||
<Card
|
||||
key={value.id}
|
||||
className="cursor-pointer transition-colors duration-150 hover:bg-secondary/60"
|
||||
onClick={() => setDialog({ mode: 'edit', value })}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm">{value.code}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||
{value.name}
|
||||
</p>
|
||||
</div>
|
||||
{renderStatusBadge(value)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isProjectTab && (value.start_date || value.end_date) && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{value.start_date ? formatDate(value.start_date) : '—'}
|
||||
{' – '}
|
||||
{value.end_date ? formatDate(value.end_date) : '—'}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create/edit dialog */}
|
||||
<Dialog open={dialog !== null} onOpenChange={(open) => !open && setDialog(null)}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dialog?.mode === 'edit'
|
||||
? t('edit_value_title')
|
||||
: t('new_value_title', { dimension: activeDim?.name ?? '' })}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{activeDim && dialog && (
|
||||
<DimensionValueForm
|
||||
key={dialog.mode === 'edit' ? dialog.value.id : 'create'}
|
||||
dimension={activeDim}
|
||||
value={dialog.mode === 'edit' ? dialog.value : null}
|
||||
isSaving={isSaving}
|
||||
onSubmit={handleSubmitValue}
|
||||
onDelete={dialog.mode === 'edit' ? handleDeleteValue : undefined}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Client-side contract types for the dimensions registry API (PR2 of
|
||||
* dev_docs/dimensions_implementation_plan.md).
|
||||
*
|
||||
* The routes live under /api/dimensions and are built against the same locked
|
||||
* contract — this module codes against the contract, not the route files, so
|
||||
* the register UI (DimensionsManager) and the shared picker (DimensionCombobox)
|
||||
* can ship independently of the API package.
|
||||
*/
|
||||
|
||||
export interface DimensionValueDto {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
export interface DimensionDto {
|
||||
id: string
|
||||
/** SIE #DIM number (1 = Kostnadsställe, 6 = Projekt). */
|
||||
sie_dim_no: number
|
||||
name: string
|
||||
resets_annually: boolean
|
||||
is_system: boolean
|
||||
is_active: boolean
|
||||
sort_order: number
|
||||
/** Sorted by code by the API. */
|
||||
values: DimensionValueDto[]
|
||||
}
|
||||
|
||||
/** SIE dimension number whose values carry start/end dates (Projekt). */
|
||||
export const PROJECT_DIM_NO = 6
|
||||
|
||||
/**
|
||||
* Strict Fortnox-compatible code format enforced by the API for user-created
|
||||
* codes (the DB CHECK is deliberately looser so legacy free-text survives the
|
||||
* backfill). Mirrored client-side for inline validation before POST.
|
||||
*/
|
||||
export const DIMENSION_CODE_PATTERN = /^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/
|
||||
|
||||
/**
|
||||
* Load the company's dimension registry. The handler lazily seeds system dims
|
||||
* 1/6 via ensure_company_dimensions, so the result always contains at least
|
||||
* Kostnadsställe + Projekt. Throws the parsed error envelope on failure so
|
||||
* callers can hand it straight to getErrorMessage().
|
||||
*/
|
||||
export async function fetchDimensions(): Promise<DimensionDto[]> {
|
||||
const res = await fetch('/api/dimensions')
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
throw json ?? new Error('Failed to load dimensions')
|
||||
}
|
||||
return (json?.dimensions ?? []) as DimensionDto[]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
|
||||
/**
|
||||
* Company-level toggle for the dimensions register (kostnadsställen &
|
||||
* projekt). Persists company_settings.dimensions_enabled through the standard
|
||||
* settings PUT — the flag gates UI visibility only (nav row + register),
|
||||
* never correctness (dimensions plan §2).
|
||||
*
|
||||
* Toggling ON runs the "Importera befintliga koder" scan
|
||||
* (POST /api/dimensions/import-existing): codes already present on
|
||||
* journal_entry_lines.dimensions but missing from the registry are created as
|
||||
* archived placeholder values, and the user is told how many were found.
|
||||
*/
|
||||
export function DimensionsToggle() {
|
||||
const t = useTranslations('dimensions')
|
||||
const errorLocale = useLocale() as ErrorLocale
|
||||
const { settings, updateSettings } = useSettings()
|
||||
const { canWrite } = useCanWrite()
|
||||
const { toast } = useToast()
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const enabled = settings?.dimensions_enabled ?? false
|
||||
|
||||
async function handleChange(next: boolean) {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dimensions_enabled: next }),
|
||||
})
|
||||
const json = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('settings_save_failed_title'),
|
||||
description: getErrorMessage(json, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
updateSettings({ dimensions_enabled: next })
|
||||
|
||||
if (next) {
|
||||
// Import scan: registry rows for codes already used on lines. Failure
|
||||
// is non-fatal — the toggle stays on and the scan can be re-run by
|
||||
// toggling again.
|
||||
try {
|
||||
const importRes = await fetch('/api/dimensions/import-existing', {
|
||||
method: 'POST',
|
||||
})
|
||||
const importJson = await importRes.json().catch(() => null)
|
||||
if (importRes.ok) {
|
||||
const created: number =
|
||||
importJson?.created ?? importJson?.data?.created ?? 0
|
||||
if (created > 0) {
|
||||
toast({
|
||||
title: t('settings_imported_toast_title'),
|
||||
description: t('settings_imported_toast', { count: created }),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: t('settings_import_failed_title'),
|
||||
description: getErrorMessage(importJson, { locale: errorLocale }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: t('settings_import_failed_title'),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{t('settings_heading')}
|
||||
</h2>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dimensions-enabled" className="text-sm">
|
||||
{t('settings_toggle_label')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
{t('settings_toggle_help')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="dimensions-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(next) => void handleChange(next)}
|
||||
disabled={isSaving || !canWrite}
|
||||
/>
|
||||
</div>
|
||||
{enabled && (
|
||||
<Link
|
||||
href="/dimensions"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t('settings_open_register')}
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager
|
||||
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
|
||||
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { DimensionsToggle } from '@/components/settings/DimensionsToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -165,6 +166,11 @@ export function BookkeepingSettingsContent() {
|
||||
<PeriodiseringAutoDetectToggle />
|
||||
</div>
|
||||
|
||||
{/* Kostnadsställen & projekt (dimensions) toggle */}
|
||||
<div className="border-t border-border pt-8">
|
||||
<DimensionsToggle />
|
||||
</div>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
|
||||
@@ -713,6 +713,67 @@ export const CorrectJournalEntrySchema = z.object({
|
||||
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Dimension registry schemas (kostnadsställe/projekt)
|
||||
// ============================================================
|
||||
// dev_docs/dimensions_implementation_plan.md §6. The registry tables
|
||||
// (dimensions/dimension_values) shipped in 20260702084500_dimensions_substrate.
|
||||
|
||||
/**
|
||||
* Object code for USER-CREATED dimension values: strict Fortnox format.
|
||||
* Deliberately tighter than both the DB CHECK (1..40 chars, no `"{}`') and
|
||||
* DimensionsBagSchema (line-level values) — legacy free-text codes from the
|
||||
* backfill/SIE import must survive on lines, but new registry codes minted
|
||||
* through the API stay portable to Fortnox/Visma.
|
||||
*/
|
||||
const dimensionValueCode = z
|
||||
.string()
|
||||
.regex(
|
||||
/^[A-Za-z0-9ÅÄÖåäö_+\-]{1,20}$/,
|
||||
'Koden får bara innehålla bokstäver (A–Ö), siffror, _, + och - (max 20 tecken)',
|
||||
)
|
||||
|
||||
const dimensionValueDates = {
|
||||
start_date: isoDate.nullable().optional(),
|
||||
end_date: isoDate.nullable().optional(),
|
||||
}
|
||||
|
||||
/** PATCH /api/dimensions/[id] — name is rejected route-side for is_system dims. */
|
||||
export const UpdateDimensionSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
})
|
||||
.refine((body) => Object.values(body).some((v) => v !== undefined), {
|
||||
message: 'Minst ett fält måste anges',
|
||||
})
|
||||
|
||||
/** POST /api/dimensions/[id]/values — code is immutable after creation (v1: no rename). */
|
||||
export const CreateDimensionValueSchema = z
|
||||
.object({
|
||||
code: dimensionValueCode,
|
||||
name: z.string().min(1).max(120),
|
||||
/** Omitted → true. Lets "create as archived" be a single atomic POST. */
|
||||
is_active: z.boolean().optional(),
|
||||
...dimensionValueDates,
|
||||
})
|
||||
.refine(
|
||||
(body) => !body.start_date || !body.end_date || body.end_date >= body.start_date,
|
||||
{ message: 'Slutdatum får inte vara före startdatum', path: ['end_date'] },
|
||||
)
|
||||
|
||||
/** PATCH /api/dimensions/[id]/values/[valueId] — no `code` field by design. */
|
||||
export const UpdateDimensionValueSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
...dimensionValueDates,
|
||||
})
|
||||
.refine((body) => Object.values(body).some((v) => v !== undefined), {
|
||||
message: 'Minst ett fält måste anges',
|
||||
})
|
||||
|
||||
/**
|
||||
* Move a posted verifikation to a different date (and thereby fiscal period)
|
||||
* without changing its lines — fixes a booking entered with the wrong
|
||||
@@ -1172,6 +1233,9 @@ export const UpdateSettingsSchema = z.object({
|
||||
.optional(),
|
||||
// AI agent flow
|
||||
ai_flow_enabled: z.boolean().optional(),
|
||||
// Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never
|
||||
// load-bearing for correctness (dev_docs/dimensions_implementation_plan.md §2).
|
||||
dimensions_enabled: z.boolean().optional(),
|
||||
// Salary payment file
|
||||
preferred_payment_format: z.enum(['bg_lb', 'pain001']).optional(),
|
||||
}).refine(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `102`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `104`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
@@ -14,6 +14,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies/:companyId/compliance/check",
|
||||
"GET /api/v1/companies/:companyId/customers",
|
||||
"GET /api/v1/companies/:companyId/customers/:id",
|
||||
"GET /api/v1/companies/:companyId/dimensions",
|
||||
"GET /api/v1/companies/:companyId/documents/:id/download",
|
||||
"GET /api/v1/companies/:companyId/employees",
|
||||
"GET /api/v1/companies/:companyId/employees/:id",
|
||||
@@ -60,6 +61,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"PATCH /api/v1/companies/:companyId/webhooks/:id",
|
||||
"POST /api/v1/companies/:companyId/customers",
|
||||
"POST /api/v1/companies/:companyId/customers/bulk-create",
|
||||
"POST /api/v1/companies/:companyId/dimensions/:id/values",
|
||||
"POST /api/v1/companies/:companyId/documents",
|
||||
"POST /api/v1/companies/:companyId/documents/:id/link",
|
||||
"POST /api/v1/companies/:companyId/employees",
|
||||
|
||||
@@ -132,4 +132,8 @@ import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route'
|
||||
// Inbox item stamp.
|
||||
import '@/app/api/v1/companies/[companyId]/inbox-items/[id]/stamp/route'
|
||||
|
||||
// Dimensions PR2 — registry list + value creation (kostnadsställe/projekt).
|
||||
import '@/app/api/v1/companies/[companyId]/dimensions/route'
|
||||
import '@/app/api/v1/companies/[companyId]/dimensions/[id]/values/route'
|
||||
|
||||
export {}
|
||||
|
||||
@@ -181,6 +181,12 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write',
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write',
|
||||
|
||||
// Dimensions (kostnadsställe/projekt) — dimensions PR2. Reads ride
|
||||
// reports:read (registry data feeds report filters/pickers); value creation
|
||||
// is bookkeeping:write (it mints codes that journal lines reference).
|
||||
'GET /api/v1/companies/:companyId/dimensions': 'reports:read',
|
||||
'POST /api/v1/companies/:companyId/dimensions/:id/values': 'bookkeeping:write',
|
||||
|
||||
// Webhooks (Phase 6 PR-1)
|
||||
'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||
'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage',
|
||||
|
||||
@@ -2462,6 +2462,75 @@ const ASSETS: Record<string, StructuredErrorEntry> = {
|
||||
},
|
||||
}
|
||||
|
||||
// Dimensions registry (kostnadsställe/projekt) — dev_docs/dimensions_implementation_plan.md §6
|
||||
const DIMENSION: Record<string, StructuredErrorEntry> = {
|
||||
DIMENSION_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Dimensionen kunde inte hittas.',
|
||||
message_en: 'Dimension not found.',
|
||||
},
|
||||
DIMENSION_SYSTEM_RENAME: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Systemdimensioner kan inte döpas om.',
|
||||
message_en: 'System dimensions (kostnadsställe/projekt) cannot be renamed.',
|
||||
},
|
||||
DIMENSION_UPDATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Dimensionen kunde inte uppdateras.',
|
||||
message_en: 'Failed to update dimension.',
|
||||
},
|
||||
DIMENSION_VALUE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Dimensionsvärdet kunde inte hittas.',
|
||||
message_en: 'Dimension value not found.',
|
||||
},
|
||||
DIMENSION_VALUE_DUPLICATE_CODE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Ett värde med samma kod finns redan i dimensionen.',
|
||||
message_en: 'A value with that code already exists in the dimension.',
|
||||
},
|
||||
DIMENSION_VALUE_DATES_NOT_ALLOWED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Datum kan bara sättas på ackumulerande dimensioner (t.ex. projekt).',
|
||||
message_en:
|
||||
'Start/end dates can only be set on accumulating dimensions (e.g. projects) — this dimension resets annually.',
|
||||
},
|
||||
DIMENSION_VALUE_CREATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Dimensionsvärdet kunde inte skapas.',
|
||||
message_en: 'Failed to create dimension value.',
|
||||
},
|
||||
DIMENSION_VALUE_UPDATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Dimensionsvärdet kunde inte uppdateras.',
|
||||
message_en: 'Failed to update dimension value.',
|
||||
},
|
||||
// The DB retention trigger (enforce_dimension_value_retention) raises when a
|
||||
// code is referenced by posted/reversed lines. Routes surface the trigger's
|
||||
// own Swedish message via `messageSv` so the code + kod appear in the toast.
|
||||
DIMENSION_VALUE_REFERENCED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Värdet används på bokförda verifikat och kan inte tas bort — arkivera det istället.',
|
||||
message_en:
|
||||
'The value is referenced by posted vouchers and cannot be deleted — archive (inactivate) it instead.',
|
||||
remediation: {
|
||||
description:
|
||||
'Archive the value instead: PATCH the dimension value with { "is_active": false }. Codes referenced by posted lines are retained for the BFL 7-year period.',
|
||||
},
|
||||
},
|
||||
DIMENSION_VALUE_DELETE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Dimensionsvärdet kunde inte tas bort.',
|
||||
message_en: 'Failed to delete dimension value.',
|
||||
},
|
||||
DIMENSION_IMPORT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Import av befintliga dimensionskoder misslyckades.',
|
||||
message_en: 'Failed to import existing dimension codes from journal lines.',
|
||||
},
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Combined registry
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -2505,6 +2574,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
|
||||
...SKATTEVERKET,
|
||||
...BOLAGSVERKET,
|
||||
...ASSETS,
|
||||
...DIMENSION,
|
||||
}
|
||||
|
||||
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
|
||||
|
||||
@@ -6,12 +6,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
// `.eq()` args recorded per table so tests can assert on query shape (e.g.
|
||||
// that the dimension registry fetch does NOT filter is_active — the latent
|
||||
// undeclared-#OBJEKT bug was exactly such a filter).
|
||||
let eqCallsByTable: Record<string, Array<[string, unknown]>>
|
||||
|
||||
function makeBuilder() {
|
||||
function makeBuilder(table: string) {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) {
|
||||
for (const m of ['select', 'in', 'order', 'range', 'lt', 'lte', 'gte', 'gt', 'limit', 'neq']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.eq = vi.fn().mockImplementation((column: string, value: unknown) => {
|
||||
;(eqCallsByTable[table] ??= []).push([column, value])
|
||||
return b
|
||||
})
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
@@ -19,7 +27,7 @@ function makeBuilder() {
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
from: vi.fn().mockImplementation((table: string) => makeBuilder(table)),
|
||||
// `rpc` drains the same queue so tests can intersperse RPC + table fetches.
|
||||
// SIE export calls `compute_prior_opening_balances` via getOpeningBalances
|
||||
// whenever `opening_balance_entry_id` is null (the multi-year-import path).
|
||||
@@ -36,6 +44,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
eqCallsByTable = {}
|
||||
supabase = makeClient()
|
||||
})
|
||||
|
||||
@@ -46,16 +55,20 @@ const baseOptions = {
|
||||
program_name: 'ERPBase',
|
||||
}
|
||||
|
||||
// Queue consumption order after the pagination fix:
|
||||
// Queue consumption order (dimensions registry replaced cost_centers/projects):
|
||||
// 0: fiscal_periods.single()
|
||||
// 1: previous fiscal period .single() (#RAR -1)
|
||||
// 2: chart_of_accounts (fetchAllRows)
|
||||
// 3: journal_entries (fetchAllRows)
|
||||
// 4: journal_entry_lines (fetchAllRows) ← split out from the entries query
|
||||
// 5: cost_centers
|
||||
// 6: projects
|
||||
// 5: dimensions (registry #DIM/#UNDERDIM rows)
|
||||
// 6: dimension_values (registry #OBJEKT rows)
|
||||
// 7: opening balances (RPC fallback or journal_entry_lines page)
|
||||
|
||||
// Registry fixtures for the system dims (seeded by ensure_company_dimensions).
|
||||
const dimKostnadsstalle = { id: 'dim-1', sie_dim_no: 1, parent_sie_dim_no: null, name: 'Kostnadsställe' }
|
||||
const dimProjekt = { id: 'dim-6', sie_dim_no: 6, parent_sie_dim_no: null, name: 'Projekt' }
|
||||
|
||||
describe('generateSIEExport', () => {
|
||||
it('throws when fiscal period not found', async () => {
|
||||
results = [
|
||||
@@ -74,8 +87,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // opening balances RPC
|
||||
]
|
||||
|
||||
@@ -99,8 +112,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -125,8 +138,8 @@ describe('generateSIEExport', () => {
|
||||
},
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -154,14 +167,14 @@ describe('generateSIEExport', () => {
|
||||
{
|
||||
// journal_entry_lines — each carries journal_entry_id for grouping
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: 'Revenue', dimensions: {} },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -175,22 +188,18 @@ describe('generateSIEExport', () => {
|
||||
expect(output).toContain('}')
|
||||
})
|
||||
|
||||
it('generates #DIM and #OBJEKT for dimensions', async () => {
|
||||
it('generates #DIM and #OBJEKT for registry dimensions', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
|
||||
{
|
||||
data: [
|
||||
{ code: 'CC1', name: 'Avdelning 1', is_active: true },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ code: 'P001', name: 'Projekt Alpha', is_active: true },
|
||||
{ dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' },
|
||||
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
@@ -205,7 +214,7 @@ describe('generateSIEExport', () => {
|
||||
expect(output).toContain('#OBJEKT 6 "P001" "Projekt Alpha"')
|
||||
})
|
||||
|
||||
it('includes dimension objects in #TRANS lines', async () => {
|
||||
it('includes dimension objects in #TRANS lines from the jsonb map', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
@@ -218,13 +227,19 @@ describe('generateSIEExport', () => {
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, cost_center: 'CC1', project: 'P001' },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 8000, credit_amount: 0, line_description: null, dimensions: { '1': 'CC1', '6': 'P001' } },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 8000, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
|
||||
{
|
||||
data: [
|
||||
{ dimension_id: 'dim-1', code: 'CC1', name: 'Avdelning 1' },
|
||||
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ code: 'CC1', name: 'Avdelning 1', is_active: true }], error: null },
|
||||
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -234,6 +249,203 @@ describe('generateSIEExport', () => {
|
||||
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
|
||||
})
|
||||
|
||||
it('declares INACTIVE registry values as #OBJEKT (undeclared-object regression)', async () => {
|
||||
// Latent bug in the legacy read path: the registry fetch filtered
|
||||
// is_active=true, so a line referencing an archived code serialized into
|
||||
// #TRANS while its #OBJEKT declaration was missing — Visma rejects such
|
||||
// files. The registry fetch must NOT filter on is_active.
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-05-02', voucher_number: 1, voucher_series: 'A', description: 'Archived code', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 500, credit_amount: 0, line_description: null, dimensions: { '1': 'CC9' } },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 500, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [dimKostnadsstalle], error: null }, // dimensions
|
||||
// The archived (is_active=false) value row IS returned by the query
|
||||
// because the export must not filter it out.
|
||||
{ data: [{ dimension_id: 'dim-1', code: 'CC9', name: 'Nedlagd avdelning', is_active: false }], error: null },
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
// Declared with its registry name (not synthesized code-as-name)
|
||||
expect(output).toContain('#DIM 1 "Kostnadsställe"')
|
||||
expect(output).toContain('#OBJEKT 1 "CC9" "Nedlagd avdelning"')
|
||||
expect(output).toContain('\t#TRANS 5010 {1 "CC9"} 500.00 20240502')
|
||||
// Query-shape guard: neither registry fetch may filter on is_active —
|
||||
// that is the exact filter that caused the undeclared-#OBJEKT bug.
|
||||
expect(eqCallsByTable['dimensions'] ?? []).not.toContainEqual(['is_active', true])
|
||||
expect(eqCallsByTable['dimension_values'] ?? []).not.toContainEqual(['is_active', true])
|
||||
})
|
||||
|
||||
it('synthesizes #DIM and #OBJEKT for orphan line codes with no registry rows', async () => {
|
||||
// Free-text writers can still mint dimension numbers/codes until the
|
||||
// write-path PR — every referenced (dimNo, code) pair must be declared,
|
||||
// never silently dropped. Dim 6 resolves from the SIE reserved-number
|
||||
// seed; dim 13 falls back to "Dimension n"; both codes get name = code.
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-02-01', voucher_number: 1, voucher_series: 'A', description: 'Orphans', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '4010', debit_amount: 900, credit_amount: 0, line_description: null, dimensions: { '6': 'GHOST', '13': 'X1' } },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 900, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // dimensions — registry is empty
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
expect(output).toContain('#DIM 6 "Projekt"')
|
||||
expect(output).toContain('#DIM 13 "Dimension 13"')
|
||||
expect(output).toContain('#OBJEKT 6 "GHOST" "GHOST"')
|
||||
expect(output).toContain('#OBJEKT 13 "X1" "X1"')
|
||||
expect(output).toContain('\t#TRANS 4010 {6 "GHOST" 13 "X1"} 900.00 20240201')
|
||||
})
|
||||
|
||||
it('serializes multi-dimension lines sorted by numeric dimension number', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-04-10', voucher_number: 1, voucher_series: 'A', description: 'Multi-dim', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
// Keys deliberately out of order — output must sort 1 < 6 < 7
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '7010', debit_amount: 30000, credit_amount: 0, line_description: null, dimensions: { '7': 'EMP1', '1': 'KS01', '6': 'P001' } },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 30000, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions
|
||||
{
|
||||
data: [
|
||||
{ dimension_id: 'dim-1', code: 'KS01', name: 'Kontoret' },
|
||||
{ dimension_id: 'dim-6', code: 'P001', name: 'Projekt Alpha' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
expect(output).toContain('\t#TRANS 7010 {1 "KS01" 6 "P001" 7 "EMP1"} 30000.00 20240410')
|
||||
// Dim 7 has no registry row → synthesized from the SIE reserved seed,
|
||||
// with the orphan employee code declared.
|
||||
expect(output).toContain('#DIM 7 "Anställd"')
|
||||
expect(output).toContain('#OBJEKT 7 "EMP1" "EMP1"')
|
||||
})
|
||||
|
||||
it('emits #UNDERDIM for child dimensions and declares the parent', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{
|
||||
// Kostnadsbärare (2) is a sub-dimension of Kostnadsställe (1); the
|
||||
// parent has NO values of its own — it must still be declared because
|
||||
// an #UNDERDIM referencing an undeclared parent is invalid.
|
||||
data: [
|
||||
dimKostnadsstalle,
|
||||
{ id: 'dim-2', sie_dim_no: 2, parent_sie_dim_no: 1, name: 'Kostnadsbärare' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [{ dimension_id: 'dim-2', code: 'KB1', name: 'Bärare 1' }], error: null },
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
expect(output).toContain('#DIM 1 "Kostnadsställe"')
|
||||
expect(output).toContain('#UNDERDIM 2 "Kostnadsbärare" 1')
|
||||
expect(output).toContain('#OBJEKT 2 "KB1" "Bärare 1"')
|
||||
// The parent was pulled in as a declaration only — no #UNDERDIM for it
|
||||
expect(output).not.toContain('#UNDERDIM 1')
|
||||
})
|
||||
|
||||
it('declares a parent #DIM before an #UNDERDIM child with a LOWER number', async () => {
|
||||
// SIE4 requires the parent to be declared before any #UNDERDIM that
|
||||
// references it. A registry can hold a child whose sie_dim_no is LOWER
|
||||
// than its parent's (dim 3 under dim 7 here), so a single numeric sort
|
||||
// would emit the #UNDERDIM first — the two-pass emit (#DIM roots first,
|
||||
// then #UNDERDIM) must keep the parent ahead.
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{
|
||||
data: [
|
||||
{ id: 'e1', entry_date: '2024-03-01', voucher_number: 1, voucher_series: 'A', description: 'Child below parent', status: 'posted' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '5010', debit_amount: 700, credit_amount: 0, line_description: null, dimensions: { '3': 'UND1', '7': 'EMP1' } },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 0, credit_amount: 700, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
// dim 3 is a child of dim 7 — numerically BEFORE its parent.
|
||||
data: [
|
||||
{ id: 'dim-3', sie_dim_no: 3, parent_sie_dim_no: 7, name: 'Underavdelning' },
|
||||
{ id: 'dim-7', sie_dim_no: 7, parent_sie_dim_no: null, name: 'Anställd' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ dimension_id: 'dim-3', code: 'UND1', name: 'Under 1' },
|
||||
{ dimension_id: 'dim-7', code: 'EMP1', name: 'Anna' },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
expect(output).toContain('#DIM 7 "Anställd"')
|
||||
expect(output).toContain('#UNDERDIM 3 "Underavdelning" 7')
|
||||
// The parent #DIM must precede the child #UNDERDIM in the file.
|
||||
expect(output.indexOf('#DIM 7 "Anställd"')).toBeLessThan(
|
||||
output.indexOf('#UNDERDIM 3 "Underavdelning" 7'),
|
||||
)
|
||||
})
|
||||
|
||||
it('generates #UB for class 1-2 and #RES for class 3-8', async () => {
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
@@ -247,14 +459,14 @@ describe('generateSIEExport', () => {
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '1510', debit_amount: 1250, credit_amount: 0, line_description: null, dimensions: {} },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 1000, line_description: null, dimensions: {} },
|
||||
{ journal_entry_id: 'e1', account_number: '2611', debit_amount: 0, credit_amount: 250, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -281,13 +493,13 @@ describe('generateSIEExport', () => {
|
||||
},
|
||||
{
|
||||
data: [
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
|
||||
{ journal_entry_id: 'e1', account_number: '1930', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} },
|
||||
{ journal_entry_id: 'e1', account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -303,8 +515,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -328,8 +540,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -346,8 +558,29 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
const output = await generateSIEExport(supabase, 'company-1', baseOptions)
|
||||
|
||||
expect(output).not.toContain('#DIM')
|
||||
expect(output).not.toContain('#OBJEKT')
|
||||
})
|
||||
|
||||
it('keeps seeded-but-unused system dimensions silent (no #DIM without values or tagged lines)', async () => {
|
||||
// ensure_company_dimensions lazily seeds dims 1/6 for any company that
|
||||
// touches the dimensions UI — a company that merely visited the register
|
||||
// page must still get a dimension-free file.
|
||||
results = [
|
||||
{ data: { id: 'period-1', period_start: '2024-01-01', period_end: '2024-12-31' }, error: null },
|
||||
{ data: null, error: null }, // prevPeriod
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [dimKostnadsstalle, dimProjekt], error: null }, // dimensions — seeded, valueless
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -375,8 +608,8 @@ describe('generateSIEExport', () => {
|
||||
}))
|
||||
|
||||
const lines = entries.flatMap((e, i) => [
|
||||
{ id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, cost_center: null, project: null },
|
||||
{ id: `l${i * 2 + 1}`, journal_entry_id: e.id, account_number: '1510', debit_amount: 100, credit_amount: 0, line_description: null, dimensions: {} },
|
||||
{ id: `l${i * 2 + 2}`, journal_entry_id: e.id, account_number: '3001', debit_amount: 0, credit_amount: 100, line_description: null, dimensions: {} },
|
||||
])
|
||||
|
||||
// fetchAllRows paginates at PAGE_SIZE = 1000; chunk the mock data so the
|
||||
@@ -399,8 +632,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
...paginate(entries), // journal_entries — 3 pages (1000 + 1000 + 500)
|
||||
...paginate(lines), // journal_entry_lines — 5000 rows → 5 pages
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
{ data: [], error: null }, // RPC fallback
|
||||
]
|
||||
|
||||
@@ -433,8 +666,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries (no movements this period)
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
// RPC fallback returns prior IBs derived from historical journal lines
|
||||
{
|
||||
data: [
|
||||
@@ -464,8 +697,8 @@ describe('generateSIEExport', () => {
|
||||
{ data: [], error: null }, // accounts
|
||||
{ data: [], error: null }, // journal_entries
|
||||
{ data: [], error: null }, // journal_entry_lines
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
// fetchAllRows page 1 — explicit OB entry lines
|
||||
{
|
||||
data: [
|
||||
@@ -526,15 +759,15 @@ describe('generateSIEExport', () => {
|
||||
// movement via obEntryId) and the real transfer's lines both flow through here.
|
||||
{
|
||||
data: [
|
||||
{ id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', cost_center: null, project: null },
|
||||
{ id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
|
||||
{ id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, cost_center: null, project: null },
|
||||
{ id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, cost_center: null, project: null },
|
||||
{ id: 'l1', journal_entry_id: 'ob-entry-1', account_number: '1933', debit_amount: 96466.59, credit_amount: 0, line_description: 'IB 1933', dimensions: {} },
|
||||
{ id: 'l2', journal_entry_id: 'ob-entry-1', account_number: '2019', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} },
|
||||
{ id: 'l3', journal_entry_id: 'e2', account_number: '1930', debit_amount: 96466.59, credit_amount: 0, line_description: null, dimensions: {} },
|
||||
{ id: 'l4', journal_entry_id: 'e2', account_number: '1933', debit_amount: 0, credit_amount: 96466.59, line_description: null, dimensions: {} },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
{ data: [], error: null }, // cost_centers
|
||||
{ data: [], error: null }, // projects
|
||||
{ data: [], error: null }, // dimensions
|
||||
{ data: [], error: null }, // dimension_values
|
||||
// fetchAllRows for OB entry lines (opening_balance_entry_id is set)
|
||||
{
|
||||
data: [
|
||||
|
||||
+221
-37
@@ -1,9 +1,12 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { getOpeningBalances } from './opening-balances'
|
||||
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
const log = createLogger('reports:sie-export')
|
||||
|
||||
function sanitizeProgramName(str: string): string {
|
||||
return str.replace(/"/g, '').replace(/[\r\n]/g, ' ').substring(0, 60)
|
||||
}
|
||||
@@ -142,19 +145,21 @@ export async function generateSIEExport(
|
||||
entry.lines = linesByEntryId.get(entry.id) || []
|
||||
}
|
||||
|
||||
// Fetch cost centers and projects for dimension records
|
||||
const { data: costCenters } = await supabase
|
||||
.from('cost_centers')
|
||||
.select('*')
|
||||
// Fetch the dimension registry (#DIM/#UNDERDIM + #OBJEKT source).
|
||||
// Deliberately NO is_active filter: lines referencing archived codes still
|
||||
// serialize into #TRANS object lists, and Visma rejects files whose #TRANS
|
||||
// references an undeclared #OBJEKT (plan §5 latent bug #2 — the legacy
|
||||
// cost_centers/projects read filtered is_active=true and dropped them).
|
||||
const { data: registryDimensions } = await supabase
|
||||
.from('dimensions')
|
||||
.select('id, sie_dim_no, parent_sie_dim_no, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('code')
|
||||
.order('sie_dim_no')
|
||||
|
||||
const { data: projects } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
const { data: registryValues } = await supabase
|
||||
.from('dimension_values')
|
||||
.select('dimension_id, code, name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('code')
|
||||
|
||||
const lines: string[] = []
|
||||
@@ -183,25 +188,10 @@ export async function generateSIEExport(
|
||||
lines.push(`#RAR -1 ${dateStringToSIE(prevPeriod.period_start)} ${dateStringToSIE(prevPeriod.period_end)}`)
|
||||
}
|
||||
|
||||
// === Dimension definitions ===
|
||||
// SIE standard: dimension 1 = kostnadsställe, dimension 6 = projekt
|
||||
const hasCostCenters = costCenters && costCenters.length > 0
|
||||
const hasProjects = projects && projects.length > 0
|
||||
|
||||
if (hasCostCenters) {
|
||||
lines.push('#DIM 1 "Kostnadsställe"')
|
||||
}
|
||||
if (hasProjects) {
|
||||
lines.push('#DIM 6 "Projekt"')
|
||||
}
|
||||
|
||||
// === Dimension objects (#OBJEKT) ===
|
||||
for (const cc of costCenters || []) {
|
||||
lines.push(`#OBJEKT 1 "${escapeQuotes(cc.code)}" "${escapeQuotes(cc.name)}"`)
|
||||
}
|
||||
for (const proj of projects || []) {
|
||||
lines.push(`#OBJEKT 6 "${escapeQuotes(proj.code)}" "${escapeQuotes(proj.name)}"`)
|
||||
}
|
||||
// === Dimension definitions (#DIM / #UNDERDIM) + objects (#OBJEKT) ===
|
||||
lines.push(
|
||||
...buildDimensionSection(registryDimensions ?? [], registryValues ?? [], allLines)
|
||||
)
|
||||
|
||||
// === Chart of accounts ===
|
||||
for (const account of (accounts as BASAccount[]) || []) {
|
||||
@@ -257,14 +247,12 @@ export async function generateSIEExport(
|
||||
? ` "${escapeQuotes(line.line_description)}"`
|
||||
: ''
|
||||
|
||||
// Build dimension object list for #TRANS line
|
||||
const dimParts: string[] = []
|
||||
if (line.cost_center) {
|
||||
dimParts.push(`1 "${escapeQuotes(line.cost_center)}"`)
|
||||
}
|
||||
if (line.project) {
|
||||
dimParts.push(`6 "${escapeQuotes(line.project)}"`)
|
||||
}
|
||||
// Build dimension object list for #TRANS from the jsonb map — the
|
||||
// single source of truth. The legacy cost_center/project columns are
|
||||
// derived mirrors of keys '1'/'6' and are no longer read here.
|
||||
const dimParts = lineDimensionEntries(line.dimensions).map(
|
||||
([dimNo, code]) => `${dimNo} "${escapeQuotes(code)}"`
|
||||
)
|
||||
const objList = dimParts.length > 0 ? `{${dimParts.join(' ')}}` : '{}'
|
||||
|
||||
lines.push(`\t#TRANS ${line.account_number} ${objList} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
|
||||
@@ -334,6 +322,202 @@ function escapeQuotes(str: string): string {
|
||||
return str.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
// ── Dimensions (#DIM / #UNDERDIM / #OBJEKT) ─────────────────────────────────
|
||||
|
||||
interface RegistryDimension {
|
||||
id: string
|
||||
sie_dim_no: number
|
||||
parent_sie_dim_no: number | null
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RegistryValue {
|
||||
dimension_id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* SIE reserved dimension numbers. Used to synthesize a #DIM declaration for
|
||||
* dimension numbers referenced by exported lines but absent from the registry
|
||||
* — free-text writers can still mint arbitrary numbers until the write-path
|
||||
* PR lands, and an undeclared dimension would make importers reject the file.
|
||||
* Dim 2 (kostnadsbärare) is a reserved sub-dimension of 1 → #UNDERDIM.
|
||||
*/
|
||||
const SIE_RESERVED_DIMENSIONS: Record<number, { name: string; parent?: number }> = {
|
||||
1: { name: 'Kostnadsställe' },
|
||||
2: { name: 'Kostnadsbärare', parent: 1 },
|
||||
6: { name: 'Projekt' },
|
||||
7: { name: 'Anställd' },
|
||||
8: { name: 'Kund' },
|
||||
9: { name: 'Leverantör' },
|
||||
10: { name: 'Faktura' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a line's jsonb dimensions map ({"1":"KS01","6":"P001"}) into
|
||||
* [dimNo, code] entries sorted by numeric dimension number. Defensive on
|
||||
* shape: non-numeric keys and blank codes are skipped, and duplicate keys
|
||||
* ('01' vs '1') collapse onto the canonical number with last-write-wins —
|
||||
* mirroring normalizeLineDimensions in lib/bookkeeping/dimension-resolver.ts.
|
||||
*/
|
||||
function lineDimensionEntries(dimensions: unknown): Array<[number, string]> {
|
||||
if (!dimensions || typeof dimensions !== 'object' || Array.isArray(dimensions)) {
|
||||
return []
|
||||
}
|
||||
const byDimNo = new Map<number, string>()
|
||||
for (const [key, value] of Object.entries(dimensions as Record<string, unknown>)) {
|
||||
if (!/^\d+$/.test(key)) continue
|
||||
const dimNo = Number(key)
|
||||
if (dimNo < 1) continue
|
||||
const code = typeof value === 'string' ? value.trim() : ''
|
||||
if (!code) continue
|
||||
byDimNo.set(dimNo, code)
|
||||
}
|
||||
return [...byDimNo.entries()].sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the #DIM/#UNDERDIM + #OBJEKT section from the dimension registry and
|
||||
* the exported journal lines.
|
||||
*
|
||||
* Completeness guarantee: every (dimNo, code) pair referenced by any exported
|
||||
* line gets an #OBJEKT record — registry rows contribute their name, orphan
|
||||
* codes (no registry row) synthesize name = code, and dimension numbers with
|
||||
* no registry row synthesize a #DIM from the SIE reserved-number seed. A file
|
||||
* whose #TRANS references an undeclared object is rejected by Visma et al.
|
||||
*
|
||||
* Silence guarantee: registry dimensions with no values and no line
|
||||
* references (e.g. lazily seeded system dims 1/6 that were never used) emit
|
||||
* nothing, so companies that never touch dimensions keep dimension-free files.
|
||||
*/
|
||||
function buildDimensionSection(
|
||||
registryDimensions: RegistryDimension[],
|
||||
registryValues: RegistryValue[],
|
||||
journalLines: JournalEntryLine[]
|
||||
): string[] {
|
||||
// Defence in depth: a value row whose dimension_id doesn't resolve in
|
||||
// dimNoById is skipped by construction (the `continue` below). Both fetches
|
||||
// are scoped to the same company_id (query filter + RLS), so every
|
||||
// dimension_id in registryValues should resolve; a miss can only mean the
|
||||
// dimension row vanished mid-export — skipping just omits its #OBJEKT,
|
||||
// never leaks a foreign company's data into the file.
|
||||
const dimsByNo = new Map<number, RegistryDimension>()
|
||||
const dimNoById = new Map<string, number>()
|
||||
for (const dim of registryDimensions) {
|
||||
dimsByNo.set(dim.sie_dim_no, dim)
|
||||
dimNoById.set(dim.id, dim.sie_dim_no)
|
||||
}
|
||||
|
||||
// Registry values grouped by dimension number: dimNo → (code → name)
|
||||
const valuesByDimNo = new Map<number, Map<string, string>>()
|
||||
for (const value of registryValues) {
|
||||
const dimNo = dimNoById.get(value.dimension_id)
|
||||
if (dimNo === undefined) continue
|
||||
let codeMap = valuesByDimNo.get(dimNo)
|
||||
if (!codeMap) {
|
||||
codeMap = new Map()
|
||||
valuesByDimNo.set(dimNo, codeMap)
|
||||
}
|
||||
if (!codeMap.has(value.code)) codeMap.set(value.code, value.name)
|
||||
}
|
||||
|
||||
// (dimNo, code) pairs referenced by exported lines
|
||||
const referencedByDimNo = new Map<number, Set<string>>()
|
||||
for (const line of journalLines) {
|
||||
for (const [dimNo, code] of lineDimensionEntries(line.dimensions)) {
|
||||
let codes = referencedByDimNo.get(dimNo)
|
||||
if (!codes) {
|
||||
codes = new Set()
|
||||
referencedByDimNo.set(dimNo, codes)
|
||||
}
|
||||
codes.add(code)
|
||||
}
|
||||
}
|
||||
|
||||
const emitDimNos = new Set<number>([
|
||||
...valuesByDimNo.keys(),
|
||||
...referencedByDimNo.keys(),
|
||||
])
|
||||
|
||||
const declarationFor = (dimNo: number): { name: string; parent: number | null } => {
|
||||
const dim = dimsByNo.get(dimNo)
|
||||
if (dim) return { name: dim.name, parent: dim.parent_sie_dim_no ?? null }
|
||||
const reserved = SIE_RESERVED_DIMENSIONS[dimNo]
|
||||
return { name: reserved?.name ?? `Dimension ${dimNo}`, parent: reserved?.parent ?? null }
|
||||
}
|
||||
|
||||
// An #UNDERDIM must not reference an undeclared parent — pull parents into
|
||||
// the emit set transitively (the has-guard also breaks registry cycles).
|
||||
const pending = [...emitDimNos]
|
||||
while (pending.length > 0) {
|
||||
const { parent } = declarationFor(pending.pop()!)
|
||||
if (parent !== null && !emitDimNos.has(parent)) {
|
||||
emitDimNos.add(parent)
|
||||
pending.push(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDimNos = [...emitDimNos].sort((a, b) => a - b)
|
||||
const out: string[] = []
|
||||
|
||||
// Synthesized placeholders collected for the operator warning below:
|
||||
// #DIM "Dimension n" fallbacks and #OBJEKT rows with name = code.
|
||||
const synthesizedDimNos: number[] = []
|
||||
const orphanObjects: Array<{ dimNo: number; code: string }> = []
|
||||
|
||||
// Two passes: every root #DIM first (sorted by sie_dim_no), then every
|
||||
// #UNDERDIM (sorted by sie_dim_no). SIE4 requires a parent to be declared
|
||||
// before any #UNDERDIM referencing it, and a child may carry a LOWER
|
||||
// number than its parent — so a single numeric sort is not enough.
|
||||
for (const dimNo of sortedDimNos) {
|
||||
const { name, parent } = declarationFor(dimNo)
|
||||
if (parent !== null) continue
|
||||
if (!dimsByNo.has(dimNo) && !SIE_RESERVED_DIMENSIONS[dimNo]) {
|
||||
synthesizedDimNos.push(dimNo)
|
||||
}
|
||||
out.push(`#DIM ${dimNo} "${escapeQuotes(name)}"`)
|
||||
}
|
||||
|
||||
// "Dimension n" fallbacks never carry a parent (declarationFor only
|
||||
// assigns parents from the registry or the reserved seed), so #UNDERDIM
|
||||
// lines are never synthesized placeholders.
|
||||
for (const dimNo of sortedDimNos) {
|
||||
const { name, parent } = declarationFor(dimNo)
|
||||
if (parent === null) continue
|
||||
out.push(`#UNDERDIM ${dimNo} "${escapeQuotes(name)}" ${parent}`)
|
||||
}
|
||||
|
||||
for (const dimNo of sortedDimNos) {
|
||||
const registry = valuesByDimNo.get(dimNo) ?? new Map<string, string>()
|
||||
const codes = new Set<string>([
|
||||
...registry.keys(),
|
||||
...(referencedByDimNo.get(dimNo) ?? []),
|
||||
])
|
||||
for (const code of [...codes].sort((a, b) => a.localeCompare(b, 'sv'))) {
|
||||
const registryName = registry.get(code)
|
||||
if (registryName === undefined) {
|
||||
orphanObjects.push({ dimNo, code })
|
||||
}
|
||||
out.push(
|
||||
`#OBJEKT ${dimNo} "${escapeQuotes(code)}" "${escapeQuotes(registryName ?? code)}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Operators must know the file contains synthesized placeholder names
|
||||
// (BFNAR 2013:2 behandlingshistorik) — one structured warning listing the
|
||||
// pairs; silent when the registry covered everything.
|
||||
if (orphanObjects.length > 0 || synthesizedDimNos.length > 0) {
|
||||
log.warn('SIE export synthesized placeholder dimension declarations', {
|
||||
orphanObjects,
|
||||
synthesizedDimNos,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate net balances per account from journal entries
|
||||
*/
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"transactions": "Transactions",
|
||||
"bookkeeping": "Bookkeeping",
|
||||
"chart_of_accounts": "Chart of accounts",
|
||||
"dimensions": "Cost centres & projects",
|
||||
"assets": "Fixed assets",
|
||||
"reports": "Reports",
|
||||
"budgets": "Budgets",
|
||||
@@ -3394,6 +3395,59 @@
|
||||
"toast_activated_title": "Account activated",
|
||||
"toast_activated_description": "Account {number} has been added to your chart of accounts"
|
||||
},
|
||||
"dimensions": {
|
||||
"new_value": "New value",
|
||||
"new_value_title": "New value – {dimension}",
|
||||
"edit_value_title": "Edit value",
|
||||
"search_placeholder": "Search by code or name…",
|
||||
"col_code": "Code",
|
||||
"col_name": "Name",
|
||||
"col_status": "Status",
|
||||
"col_start": "Start",
|
||||
"col_end": "End",
|
||||
"status_active": "Active",
|
||||
"status_archived": "Archived",
|
||||
"load_failed_title": "Could not load the register",
|
||||
"load_failed_description": "Try again in a moment.",
|
||||
"retry": "Try again",
|
||||
"empty_title": "No values yet",
|
||||
"empty_description": "Create values for {dimension} to tag journal lines and follow up results.",
|
||||
"no_search_results_title": "No matches",
|
||||
"no_search_results_description": "No value matches “{term}”.",
|
||||
"viewer_disabled_tooltip": "You have read-only access and cannot create values",
|
||||
"created_title": "Value created",
|
||||
"created_description": "{code} has been added to the register.",
|
||||
"updated_title": "Value updated",
|
||||
"deleted_title": "Value deleted",
|
||||
"save_failed_title": "Could not save",
|
||||
"delete_failed_title": "Could not delete",
|
||||
"form_code_label": "Code",
|
||||
"form_code_help": "1–20 characters: letters, digits and _ + -",
|
||||
"form_code_immutable_help": "The code cannot be changed after the value has been created.",
|
||||
"form_code_invalid": "Invalid code: 1–20 characters, only letters, digits and _ + -",
|
||||
"form_name_label": "Name",
|
||||
"form_name_invalid": "Enter a name (1–120 characters).",
|
||||
"form_active_label": "Active",
|
||||
"form_active_help": "Archived values are hidden in pickers but remain on posted vouchers.",
|
||||
"form_start_label": "Start date",
|
||||
"form_end_label": "End date",
|
||||
"form_date_order_invalid": "The end date cannot be before the start date.",
|
||||
"form_create": "Create",
|
||||
"form_save": "Save",
|
||||
"form_delete": "Delete",
|
||||
"delete_confirm_title": "Delete this value?",
|
||||
"delete_confirm_description": "“{code}” will be permanently deleted. Values used on posted vouchers cannot be deleted — archive them instead.",
|
||||
"delete_confirm_label": "Delete",
|
||||
"delete_cancel_label": "Cancel",
|
||||
"settings_heading": "Cost centres & projects",
|
||||
"settings_toggle_label": "Enable cost centres & projects",
|
||||
"settings_toggle_help": "Shows the register in the menu and lets you tag journal lines with cost centre and project.",
|
||||
"settings_imported_toast_title": "Existing codes imported",
|
||||
"settings_imported_toast": "{count, plural, one {1 existing code was imported} other {# existing codes were imported}}",
|
||||
"settings_import_failed_title": "Could not import existing codes",
|
||||
"settings_save_failed_title": "Could not save the setting",
|
||||
"settings_open_register": "Open cost centres & projects"
|
||||
},
|
||||
"tic_workspace": {
|
||||
"toast_settings_failed": "Could not fetch settings",
|
||||
"toast_profile_failed": "Could not fetch company profile",
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"transactions": "Transaktioner",
|
||||
"bookkeeping": "Bokföring",
|
||||
"chart_of_accounts": "Kontoplan",
|
||||
"dimensions": "Kostnadsställen & projekt",
|
||||
"assets": "Anläggningstillgångar",
|
||||
"reports": "Rapporter",
|
||||
"budgets": "Budgetar",
|
||||
@@ -3394,6 +3395,59 @@
|
||||
"toast_activated_title": "Konto aktiverat",
|
||||
"toast_activated_description": "Konto {number} har lagts till i din kontoplan"
|
||||
},
|
||||
"dimensions": {
|
||||
"new_value": "Nytt värde",
|
||||
"new_value_title": "Nytt värde – {dimension}",
|
||||
"edit_value_title": "Redigera värde",
|
||||
"search_placeholder": "Sök på kod eller namn…",
|
||||
"col_code": "Kod",
|
||||
"col_name": "Namn",
|
||||
"col_status": "Status",
|
||||
"col_start": "Start",
|
||||
"col_end": "Slut",
|
||||
"status_active": "Aktiv",
|
||||
"status_archived": "Arkiverad",
|
||||
"load_failed_title": "Kunde inte hämta registret",
|
||||
"load_failed_description": "Försök igen om en stund.",
|
||||
"retry": "Försök igen",
|
||||
"empty_title": "Inga värden ännu",
|
||||
"empty_description": "Skapa värden för {dimension} för att kunna tagga verifikatrader och följa upp resultatet.",
|
||||
"no_search_results_title": "Inga träffar",
|
||||
"no_search_results_description": "Inget värde matchar ”{term}”.",
|
||||
"viewer_disabled_tooltip": "Du har läsbehörighet och kan inte skapa värden",
|
||||
"created_title": "Värde skapat",
|
||||
"created_description": "{code} har lagts till i registret.",
|
||||
"updated_title": "Värde uppdaterat",
|
||||
"deleted_title": "Värde borttaget",
|
||||
"save_failed_title": "Kunde inte spara",
|
||||
"delete_failed_title": "Kunde inte ta bort",
|
||||
"form_code_label": "Kod",
|
||||
"form_code_help": "1–20 tecken: bokstäver, siffror samt _ + -",
|
||||
"form_code_immutable_help": "Koden kan inte ändras efter att värdet har skapats.",
|
||||
"form_code_invalid": "Ogiltig kod: 1–20 tecken, endast bokstäver, siffror samt _ + -",
|
||||
"form_name_label": "Namn",
|
||||
"form_name_invalid": "Ange ett namn (1–120 tecken).",
|
||||
"form_active_label": "Aktivt",
|
||||
"form_active_help": "Arkiverade värden döljs i väljare men finns kvar på bokförda verifikat.",
|
||||
"form_start_label": "Startdatum",
|
||||
"form_end_label": "Slutdatum",
|
||||
"form_date_order_invalid": "Slutdatum kan inte vara före startdatum.",
|
||||
"form_create": "Skapa",
|
||||
"form_save": "Spara",
|
||||
"form_delete": "Ta bort",
|
||||
"delete_confirm_title": "Ta bort värdet?",
|
||||
"delete_confirm_description": "”{code}” tas bort permanent. Värden som används på bokförda verifikat kan inte tas bort — arkivera dem istället.",
|
||||
"delete_confirm_label": "Ta bort",
|
||||
"delete_cancel_label": "Avbryt",
|
||||
"settings_heading": "Kostnadsställen & projekt",
|
||||
"settings_toggle_label": "Aktivera kostnadsställen & projekt",
|
||||
"settings_toggle_help": "Visar registret i menyn och gör det möjligt att tagga verifikatrader med kostnadsställe och projekt.",
|
||||
"settings_imported_toast_title": "Befintliga koder importerade",
|
||||
"settings_imported_toast": "{count, plural, one {1 befintlig kod importerades} other {# befintliga koder importerades}}",
|
||||
"settings_import_failed_title": "Kunde inte importera befintliga koder",
|
||||
"settings_save_failed_title": "Kunde inte spara inställningen",
|
||||
"settings_open_register": "Öppna kostnadsställen & projekt"
|
||||
},
|
||||
"tic_workspace": {
|
||||
"toast_settings_failed": "Kunde inte hämta inställningar",
|
||||
"toast_profile_failed": "Kunde inte hämta företagsprofil",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Dimensions registry (PR2 of the dimensions plan — dev_docs/dimensions_implementation_plan.md §4)
|
||||
--
|
||||
-- Adds the per-company UI toggle for the kostnadsställe/projekt registry.
|
||||
-- UI-visibility only, NEVER load-bearing for correctness: dimension data
|
||||
-- written via API/MCP/SIE is always validated regardless of this flag, and
|
||||
-- reports never consult it. Free tier by founder decision 2026-07-02 — no
|
||||
-- entitlement gating anywhere in the dimensions feature.
|
||||
--
|
||||
-- pg-test: skip (plain column addition, no trigger/RPC/RLS)
|
||||
|
||||
ALTER TABLE public.company_settings
|
||||
ADD COLUMN dimensions_enabled boolean NOT NULL DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.company_settings.dimensions_enabled IS
|
||||
'UI-visibility toggle for kostnadsställen & projekt (dimensions registry, pickers, report filters). Never load-bearing for correctness — data written via API/MCP/SIE import is validated regardless. SIE import that finds dimensions may flip this on with a notice.';
|
||||
|
||||
NOTIFY pgrst, 'reload schema';
|
||||
@@ -587,6 +587,7 @@ export function makeCompanySettings(
|
||||
reminder_fee_enabled: true,
|
||||
reminder_fee_amount: 60,
|
||||
reminder_interest_rate_override: null,
|
||||
dimensions_enabled: false,
|
||||
logo_url: null,
|
||||
onboarding_step: 6,
|
||||
onboarding_complete: true,
|
||||
|
||||
@@ -306,6 +306,10 @@ export interface CompanySettings {
|
||||
// Sector
|
||||
sector_slug: string | null
|
||||
|
||||
// Dimensions (kostnadsställe/projekt) — UI-visibility toggle only, never
|
||||
// load-bearing for correctness. Free tier (founder decision 2026-07-02).
|
||||
dimensions_enabled: boolean
|
||||
|
||||
// Sandbox
|
||||
is_sandbox: boolean
|
||||
|
||||
|
||||
Reference in New Issue
Block a user