Add/api and invoice (#911)
* feat(salary): validate employee clearing/kontonummer at entry Bank details on the "Anställda" form had no structural validation, so a typo in clearing/kontonummer was saved silently and only surfaced at Bankgirot LB generation (or never, on the SEPA path). Adds a shared validator (lib/salary/payment/bank-account.ts) wired into the create dialog, edit page, CreateEmployeeSchema, and the PATCH route: 4-digit clearing or 5-digit Swedbank (8xxxx), 5-11 digit account, both-or-neither. Mirrors encodeReceiverAccount so entry-time validation matches what the payout layer can encode. Update validates only when a bank field actually changes, so legacy free-text data stays editable. Includes a conservative clearing to bank-name hint (null for unknown ranges). Per-bank mod10/mod11 checksum deferred to a soft-warning follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(chart-of-accounts): styled delete warnings and bulk select-all Replace the native window.confirm() on single-account delete with the styled DestructiveConfirmDialog, and add to the prune dialog a master 'select all unused accounts' checkbox plus an explicit confirmation step before bulk deletion. New sv/en strings for the confirm titles and actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): encrypt personnummer on v1 employee create; tolerate legacy plaintext on read The v1 REST create route stored personnummer unencrypted, which then threw ERR_CRYPTO_INVALID_AUTH_TAG on every decrypt-on-read path and 500'd the employees roster. Encrypt on write in v1 create, decrypt on read in the v1 list/detail/patch responses, and make decryptPersonnummer pass a raw 12-digit value through with a warn so a legacy plaintext row can't take the roster down. Encrypt seeded personnummer. Add a gated, idempotent backfill for existing rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(bookkeeping): save a manual entry as a reusable template Add a "Spara som mall" action to the manual journal-entry form next to the existing "Anvand mall" picker, so users can capture a booking pattern the moment they work it out. Opens the shared TemplateForm (create mode) pre-seeded from the current lines via deriveTemplateLinesFromBooking, and saves through the existing POST /api/settings/booking-templates. Rendered in both the mobile and desktop layouts and in create + edit modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(pending): label all staged operation types The Granskning list rendered the raw snake_case operation_type (e.g. create_supplier_invoice_from_inbox) for any type missing from the label map, which hogs the meta row and wraps awkwardly on mobile. Add short sv/en labels for all operation types in OPERATION_RISK_TIERS, plus a humanized fallback for future ones, and simplify the label map to a plain operation_type -> i18n-key record (the icon/variant fields were dead). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reports): let users file moms without a Skatteverket connection The momsdeklaration was never gated on the Skatteverket connection (it renders from the bookkeeping), but the not-connected "Anslut med BankID" card read as a wall. Make manual filing a first-class path: - Add a "Lämna in din momsdeklaration" card under the report with a PDF download (SKV 4700 layout, hela kronor) and a skatteverket.se link. - Add a momsdeklaration PDF route + template; buildManualFilingRows() rounds each ruta to whole kronor and recomputes ruta 49 per the SKV 4700 formula so it ties out. The PDF is a read/record copy, not a submission file (moms has no upload channel). - Offer PDF alongside Excel in the report's export menu. - Reframe the not-connected SkatteverketPanel to "Skicka direkt till Skatteverket (valfritt)". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): compact new-employee dialog and warn on bad account check digit Redesign NewEmployeeDialog into a compact layout: borderless sections split by hairline dividers (no per-section cards), a fixed header + scrolling body + solid footer (fixes content showing through the old sticky bar), and denser grids. EmployeeTaxCard gains a `flat` variant so the dialog can host it without card chrome; the edit page keeps the boxed version. Add non-blocking Swedish account check-digit validation (lib/bankgiro/account-number.ts): mod10 (reuses luhn) + mod11, with a clearing->method table from the Bankgirot "Bankernas kontonummeruppbyggnad" spec, cross-checked against jop-io/kontonummer.js and verified against a real account (Forex 9420/4172385). Surfaced as a soft warning in both employee forms; unrecognised clearings return 'unknown' so we never warn on a valid but unmapped account. Never blocks saving. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(invoices): configurable send time + editing for recurring invoices Re-register the accidentally-removed recurring cron (now hourly) and add a per-schedule send hour (Europe/Stockholm, DST-aware). The cron never sends for a past date, and the enabling migration pauses every existing schedule on deploy so nothing auto-sends behind a user's back; users reactivate consciously (with a confirm) or click "Skapa faktura nu" to send this month on demand. Automatic sending now requires a customer email. Adds a full edit flow (row click opens the prefilled form, PATCH), fixing the row-click 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(invoices): configure självfaktura via the invoice API Add an optional is_self_billed flag (plus external_invoice_number, self_billing_agreement_ref, received_date) to the public invoice-create endpoint so callers can register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§) via the API. It was previously only reachable from the internal dashboard route, so it was missing from the API docs. Extract the booking into a shared service (lib/invoices/self-billed-sale.ts) and refactor the internal /api/invoices/self-billed route to a thin wrapper over it, so the dashboard and the API cannot drift. Books as a sale (Debit 1510 / Credit 30xx+26xx) with the counterparty's number; no own number is consumed. Fields are plain optionals (no schema refine) so UpdateInvoiceSchema.omit() keeps working; required-when-self-billed is enforced in the route. Documented in the endpoint registry. No migration (columns already exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): allow a partial voucher-series-per-source-type map In Zod 4 an enum-keyed z.record is exhaustive (every source_type required), so saving a default_voucher_series_per_source_type map that omits a source type (e.g. the newly added result_appropriation) failed with "expected string, received undefined". Use partialRecord so the map can be sparse; the engine falls back to series 'A' for any unmapped key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(salary): resolve employer name via getCompanyDisplayName Payslip PDFs, the payslip email, AGI, KU10, and the BG/LB + SEPA payment files now resolve the employer name through getCompanyDisplayName (company_settings.company_name, falling back to companies.name), matching how invoices already display it. Read-side coalesce, so no migration or backfill: companies.name is write-once at onboarding and not authoritative for these surfaces. The sidebar company switcher uses the same coalesce for the non-active companies in the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(kontoplan): index-only account usage counts + lighter reference load Add a covering index on journal_entry_lines (journal_entry_id, account_number) so get_account_usage_counts becomes an index-only scan (prod worst case ~440ms). Slim /api/bookkeeping/accounts/reference to return only the company's activation rows and merge against the client-bundled BAS_REFERENCE instead of re-sending the full ~1,300-account catalog every load, and defer the BAS catalog + usage counts off the first-paint critical path in ChartOfAccountsManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * i18n(salary): add bank-account checksum warning string sv/en strings for the employee bank-account (clearing/kontonummer) soft checksum warning shown by the create/edit forms. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: update decision log Append the 2026-07-06/07 decision entries (salary employer-name coalesce, sidebar switcher, employees API personnummer fix, kontoplan load optimization, momsdeklaration manual filing, recurring invoices resend + reactivation + editing, "spara som mall", voucher-series partial map, and självfaktura via the invoice API). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address compliance-review findings on recurring invoices + moms filing - recurring cron: close the double-send window with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly runs can't both spawn from the same stale batch row - recurring edit dialog: force auto_send=false whenever the effective customer has no email, so a disabled-but-checked box can't PATCH auto_send=true after the async customer load - momsdeklaration manual-filing: truncate rutor to whole kronor (öretal faller bort per SFL 22 kap 1 §) instead of round-to-nearest, matching the SRU path Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f24ede6c0
commit
3a88b53fd9
@@ -9,6 +9,10 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { AddAccountDialog } from './AddAccountDialog'
|
||||
import { EditAccountDialog } from './EditAccountDialog'
|
||||
import { PruneAccountsDialog } from './PruneAccountsDialog'
|
||||
@@ -24,7 +28,7 @@ import {
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import type { BASAccount } from '@/types'
|
||||
import { isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
import { BAS_REFERENCE, isStandardBASAccount, type BASReferenceAccount } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -43,6 +47,8 @@ interface ReferenceAccount extends BASReferenceAccount {
|
||||
export default function ChartOfAccountsManager() {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const tCommon = useTranslations('common')
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const classLabel = (cls: number): string => {
|
||||
if (cls < 1 || cls > 8) return ''
|
||||
@@ -69,6 +75,10 @@ export default function ChartOfAccountsManager() {
|
||||
const [referenceAccounts, setReferenceAccounts] = useState<ReferenceAccount[]>([])
|
||||
const [usageCounts, setUsageCounts] = useState<Map<string, number>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
// The BAS catalog + K2 default are fetched lazily the first time the user
|
||||
// opens that tab, so they never block the default "Mina konton" view.
|
||||
const [referenceLoaded, setReferenceLoaded] = useState(false)
|
||||
const [referenceLoading, setReferenceLoading] = useState(false)
|
||||
|
||||
// Dialog state
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
@@ -90,10 +100,27 @@ export default function ChartOfAccountsManager() {
|
||||
setAccounts(data || [])
|
||||
}, [])
|
||||
|
||||
// The server sends only the company's per-account activation status; the BAS
|
||||
// catalog is static and already bundled here, so we merge client-side rather
|
||||
// than re-download ~1,300 catalog rows on every load.
|
||||
const fetchReference = useCallback(async () => {
|
||||
const res = await fetch('/api/bookkeeping/accounts/reference')
|
||||
const { data } = await res.json()
|
||||
setReferenceAccounts(data || [])
|
||||
const userMap = new Map<string, { account_number: string; is_active: boolean; is_system_account: boolean }>(
|
||||
(data || []).map(
|
||||
(a: { account_number: string; is_active: boolean; is_system_account: boolean }) => [a.account_number, a],
|
||||
),
|
||||
)
|
||||
const merged: ReferenceAccount[] = BAS_REFERENCE.map((ref) => {
|
||||
const userAccount = userMap.get(ref.account_number)
|
||||
return {
|
||||
...ref,
|
||||
is_activated: !!userAccount,
|
||||
is_active: userAccount?.is_active ?? false,
|
||||
is_system_account: userAccount?.is_system_account ?? false,
|
||||
}
|
||||
})
|
||||
setReferenceAccounts(merged)
|
||||
}, [])
|
||||
|
||||
// Per-account posting counts — drives the "Verifikat" column. Non-fatal:
|
||||
@@ -116,33 +143,63 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// First paint blocks only on the user's own chart (the default view). Usage
|
||||
// counts drive the informational "Verifikat" column and load in the
|
||||
// background; the BAS catalog + K2 setting are deferred to tab open. This
|
||||
// must NOT depend on hideK2Excluded: doing so re-ran the whole load a second
|
||||
// time once the K2 default was set, doubling every fetch on each visit.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
|
||||
// Set K2 filter default based on company settings (plan_type)
|
||||
if (hideK2Excluded === null) {
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
await fetchAccounts()
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [fetchAccounts, fetchReference, fetchUsage, hideK2Excluded])
|
||||
void fetchUsage()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchAccounts, fetchUsage])
|
||||
|
||||
// Loads the BAS catalog and the K2 default on demand, once, the first time
|
||||
// the user opens the "BAS-katalog" tab.
|
||||
const ensureReferenceLoaded = useCallback(async () => {
|
||||
if (referenceLoaded || referenceLoading) return
|
||||
setReferenceLoading(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchReference(),
|
||||
(async () => {
|
||||
if (hideK2Excluded !== null) return
|
||||
try {
|
||||
const res = await fetch('/api/settings')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
// Default to hiding K2-excluded accounts if the company uses K2 (plan_type === 'k1')
|
||||
setHideK2Excluded(data?.plan_type === 'k1')
|
||||
} else {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
} catch {
|
||||
setHideK2Excluded(false)
|
||||
}
|
||||
})(),
|
||||
])
|
||||
setReferenceLoaded(true)
|
||||
} finally {
|
||||
setReferenceLoading(false)
|
||||
}
|
||||
}, [referenceLoaded, referenceLoading, fetchReference, hideK2Excluded])
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([fetchAccounts(), fetchReference(), fetchUsage()])
|
||||
}, [fetchAccounts, fetchReference, fetchUsage])
|
||||
await Promise.all([
|
||||
fetchAccounts(),
|
||||
fetchUsage(),
|
||||
// Only refresh the catalog if the user has actually opened that tab.
|
||||
...(referenceLoaded ? [fetchReference()] : []),
|
||||
])
|
||||
}, [fetchAccounts, fetchUsage, fetchReference, referenceLoaded])
|
||||
|
||||
// -------------------------------------------
|
||||
// Actions
|
||||
@@ -166,7 +223,12 @@ export default function ChartOfAccountsManager() {
|
||||
}
|
||||
|
||||
async function deleteAccount(account: BASAccount) {
|
||||
const confirmed = window.confirm(t('delete_confirm', { number: account.account_number, name: account.account_name }))
|
||||
const confirmed = await confirm({
|
||||
title: t('delete_confirm_title'),
|
||||
description: t('delete_confirm', { number: account.account_number, name: account.account_name }),
|
||||
confirmLabel: t('delete_confirm_action'),
|
||||
cancelLabel: tCommon('cancel'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
setDeletingAccount(account.account_number)
|
||||
try {
|
||||
@@ -298,8 +360,10 @@ export default function ChartOfAccountsManager() {
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) => {
|
||||
setView(v as 'my-accounts' | 'bas-catalog')
|
||||
const next = v as 'my-accounts' | 'bas-catalog'
|
||||
setView(next)
|
||||
setExpandedClasses(new Set())
|
||||
if (next === 'bas-catalog') void ensureReferenceLoaded()
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
@@ -496,6 +560,14 @@ export default function ChartOfAccountsManager() {
|
||||
{/* BAS Catalog view */}
|
||||
{view === 'bas-catalog' && (
|
||||
<div className="space-y-2">
|
||||
{referenceLoading && referenceAccounts.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
{t('loading')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{Object.entries(groupedReference)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([cls, classAccounts]) => {
|
||||
@@ -600,7 +672,7 @@ export default function ChartOfAccountsManager() {
|
||||
)
|
||||
})}
|
||||
|
||||
{filteredReference.length === 0 && (
|
||||
{!referenceLoading && filteredReference.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
{t('no_matches')}
|
||||
@@ -611,6 +683,8 @@ export default function ChartOfAccountsManager() {
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
|
||||
<AddAccountDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags } from 'lucide-react'
|
||||
import { Plus, Trash2, AlertTriangle, Loader2, Lock, CalendarPlus, Eraser, Tags, BookmarkPlus } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
@@ -20,6 +20,8 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
|
||||
import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library'
|
||||
import { TemplateForm } from '@/components/settings/TemplateForm'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
@@ -35,7 +37,7 @@ import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types'
|
||||
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary } from '@/types'
|
||||
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
|
||||
|
||||
const CURRENCIES: { value: Currency; label: string }[] = [
|
||||
@@ -103,6 +105,10 @@ export default function JournalEntryForm({
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const t = useTranslations('journal_form')
|
||||
// Reused only for the bilingual entity-type labels the shared TemplateForm
|
||||
// expects (matches BookingTemplatesPanel); the form itself already pulls its
|
||||
// copy from this namespace.
|
||||
const tTpl = useTranslations('settings_booking_templates')
|
||||
const locale = useLocale()
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('')
|
||||
@@ -154,6 +160,10 @@ export default function JournalEntryForm({
|
||||
const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null)
|
||||
const [showCreatePeriod, setShowCreatePeriod] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
// "Spara som mall": derive a reusable template from the current kontering and
|
||||
// hand it to the shared TemplateForm (create mode). Amounts become ratios/VAT
|
||||
// rates so the mall re-computes when applied to a fresh amount later.
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false)
|
||||
// Month (YYYY-MM) of the most recently posted voucher this session. Used to
|
||||
// flag, at the review step, when the user is about to book into a different
|
||||
// month: guards against accidentally posting to the wrong month.
|
||||
@@ -597,6 +607,18 @@ export default function JournalEntryForm({
|
||||
&& submittableLines.length >= 2
|
||||
&& incompleteLineCount === 0
|
||||
|
||||
// Account number → BAS name, so derived template lines get meaningful labels.
|
||||
const accountNameMap = useMemo(
|
||||
() => Object.fromEntries(catalog.map((a) => [a.account_number, a.account_name])),
|
||||
[catalog],
|
||||
)
|
||||
// Template lines derived from the current kontering. Fewer than two usable
|
||||
// lines (a 4-digit account + an amount) disables "Spara som mall".
|
||||
const derivedTemplateLines = useMemo(
|
||||
() => deriveTemplateLinesFromBooking(lines, accountNameMap),
|
||||
[lines, accountNameMap],
|
||||
)
|
||||
|
||||
const rate = parseFloat(exchangeRate) || 0
|
||||
// If user has manually entered a foreign amount, use that; otherwise derive from SEK total
|
||||
const parsedForeignInput = parseFloat(foreignAmount) || 0
|
||||
@@ -1398,6 +1420,16 @@ export default function JournalEntryForm({
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
disabled={derivedTemplateLines.length < 2}
|
||||
title={derivedTemplateLines.length < 2 ? t('save_template_disabled_hint') : undefined}
|
||||
>
|
||||
<BookmarkPlus className="h-3 w-3 mr-1" />
|
||||
{t('save_as_template')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1586,6 +1618,16 @@ export default function JournalEntryForm({
|
||||
onApply={handleTemplateApply}
|
||||
entityType={company?.entity_type}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
disabled={derivedTemplateLines.length < 2}
|
||||
title={derivedTemplateLines.length < 2 ? t('save_template_disabled_hint') : undefined}
|
||||
>
|
||||
<BookmarkPlus className="h-3 w-3 mr-1" />
|
||||
{t('save_as_template')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t('fill_balance_hint')} {t('keyboard_hint')}
|
||||
@@ -1672,6 +1714,45 @@ export default function JournalEntryForm({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save the current kontering as a reusable template. Amounts are stored
|
||||
as ratios of the total, so the user picks a fresh amount when applying
|
||||
the mall later. The shared TemplateForm re-seeds from the derived lines
|
||||
each time the dialog opens (Radix unmounts its content when closed). */}
|
||||
<Dialog open={showSaveTemplate} onOpenChange={setShowSaveTemplate}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('save_template_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>{t('save_template_dialog_description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{showSaveTemplate && (
|
||||
<TemplateForm
|
||||
mode="create"
|
||||
entityLabels={{
|
||||
all: tTpl('entity_all'),
|
||||
enskild_firma: tTpl('entity_enskild_firma'),
|
||||
aktiebolag: tTpl('entity_aktiebolag'),
|
||||
}}
|
||||
initialTemplate={{
|
||||
id: '',
|
||||
company_id: null,
|
||||
team_id: null,
|
||||
created_by: null,
|
||||
name: description.trim(),
|
||||
description: '',
|
||||
category: 'other',
|
||||
entity_type: company?.entity_type ?? 'all',
|
||||
lines: derivedTemplateLines,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} satisfies BookingTemplateLibrary}
|
||||
onSaved={() => setShowSaveTemplate(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ActivateAccountsDialog
|
||||
open={activationDialog.open}
|
||||
accountNumbers={activationDialog.accountNumbers}
|
||||
|
||||
@@ -14,6 +14,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
DestructiveConfirmDialog,
|
||||
useDestructiveConfirm,
|
||||
} from '@/components/ui/destructive-confirm-dialog'
|
||||
import { Loader2, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
interface PruneCandidate {
|
||||
@@ -43,6 +47,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
const t = useTranslations('chart_of_accounts')
|
||||
const tCommon = useTranslations('common')
|
||||
const { toast } = useToast()
|
||||
const { dialogProps: confirmDialogProps, confirm } = useDestructiveConfirm()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [candidates, setCandidates] = useState<PruneCandidate[]>([])
|
||||
@@ -117,6 +122,12 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
[filteredCandidates, isImportedOrCustom],
|
||||
)
|
||||
|
||||
// Master toggle spans both groups, scoped to the current filter so it only
|
||||
// ever selects what the user can actually see.
|
||||
const allFilteredChecked =
|
||||
filteredCandidates.length > 0 &&
|
||||
filteredCandidates.every((c) => selected.has(c.account_number))
|
||||
|
||||
function toggleAccount(accountNumber: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -145,6 +156,13 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
|
||||
async function handlePrune() {
|
||||
if (selected.size === 0) return
|
||||
const confirmed = await confirm({
|
||||
title: t('prune_confirm_title', { count: selected.size }),
|
||||
description: t('prune_confirm_body'),
|
||||
confirmLabel: t('prune_confirm', { count: selected.size }),
|
||||
cancelLabel: tCommon('cancel'),
|
||||
})
|
||||
if (!confirmed) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts/prune', {
|
||||
@@ -225,6 +243,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={(v) => !isDeleting && onOpenChange(v)}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
@@ -254,6 +273,21 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{filteredCandidates.length > 0 && (
|
||||
<label className="flex items-center gap-3 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={allFilteredChecked}
|
||||
onCheckedChange={(checked) => toggleGroup(filteredCandidates, checked === true)}
|
||||
aria-label={t('prune_select_all')}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{t('prune_select_all')}{' '}
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
({filteredCandidates.length})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto overflow-x-hidden pr-1">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_matches')}</p>
|
||||
@@ -291,5 +325,7 @@ export function PruneAccountsDialog({ open, onOpenChange, onPruned }: PruneAccou
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DestructiveConfirmDialog {...confirmDialogProps} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Customer, Currency } from '@/types'
|
||||
import type { Customer, Currency, RecurringInvoiceSchedule } from '@/types'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
@@ -36,16 +36,18 @@ const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Fired after a successful create. Hosts close the dialog and refresh their list. */
|
||||
onCreated: () => void
|
||||
/** Fired after a successful create or edit. Hosts close the dialog and refresh their list. */
|
||||
onSaved: () => void
|
||||
/** When provided, the dialog edits this schedule (PATCH) instead of creating a new one. */
|
||||
schedule?: RecurringInvoiceSchedule
|
||||
}
|
||||
|
||||
/**
|
||||
* "Nytt schema" as a modal: mirrors NewInvoiceDialog now that regular
|
||||
* invoice creation opens in one. Card sections carry over from the old
|
||||
* /invoices/recurring/new page.
|
||||
* "Nytt schema" / "Redigera schema" as a modal: mirrors NewInvoiceDialog now
|
||||
* that regular invoice creation opens in one. Passing `schedule` switches it to
|
||||
* edit mode (prefilled form, PATCH on save).
|
||||
*/
|
||||
export default function NewRecurringScheduleDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
export default function NewRecurringScheduleDialog({ open, onOpenChange, onSaved, schedule }: Props) {
|
||||
const t = useTranslations('invoice_recurring_new')
|
||||
|
||||
return (
|
||||
@@ -61,17 +63,30 @@ export default function NewRecurringScheduleDialog({ open, onOpenChange, onCreat
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogTitle>{schedule ? t('edit_title') : t('title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewRecurringScheduleForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
<NewRecurringScheduleForm
|
||||
schedule={schedule}
|
||||
onSaved={onSaved}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// Inner component so form state resets whenever the dialog reopens (Radix
|
||||
// unmounts DialogContent children on close).
|
||||
function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
|
||||
// unmounts DialogContent children on close). This is also what makes edit mode
|
||||
// work: each open re-mounts with the current schedule's values as defaults.
|
||||
function NewRecurringScheduleForm({
|
||||
schedule,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: {
|
||||
schedule?: RecurringInvoiceSchedule
|
||||
onSaved: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const { company } = useCompany()
|
||||
const supabase = createClient()
|
||||
@@ -94,6 +109,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
customer_id: z.string().uuid(t('validation_customer_required')),
|
||||
name: z.string().min(1, t('validation_name_required')),
|
||||
day_of_month: z.number().int().min(1).max(31),
|
||||
send_hour: z.number().int().min(0).max(23),
|
||||
payment_terms_days: z.number().int().min(0).max(90),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
auto_send: z.boolean(),
|
||||
@@ -111,18 +127,45 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
defaultValues: schedule
|
||||
? {
|
||||
customer_id: schedule.customer_id,
|
||||
name: schedule.name,
|
||||
day_of_month: schedule.day_of_month,
|
||||
send_hour: schedule.send_hour ?? 8,
|
||||
payment_terms_days: schedule.payment_terms_days,
|
||||
currency: schedule.currency,
|
||||
auto_send: schedule.auto_send,
|
||||
your_reference: schedule.your_reference ?? undefined,
|
||||
our_reference: schedule.our_reference ?? undefined,
|
||||
notes: schedule.notes ?? undefined,
|
||||
items:
|
||||
schedule.items && schedule.items.length > 0
|
||||
? [...schedule.items]
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map((it) => ({
|
||||
description: it.description,
|
||||
quantity: it.quantity,
|
||||
unit: it.unit,
|
||||
unit_price: it.unit_price,
|
||||
vat_rate: (it.vat_rate as 0 | 6 | 12 | 25 | null) ?? null,
|
||||
}))
|
||||
: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
}
|
||||
: {
|
||||
customer_id: '',
|
||||
name: '',
|
||||
day_of_month: 15,
|
||||
send_hour: 8,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
auto_send: false,
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0, vat_rate: 25 }],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
|
||||
@@ -140,20 +183,23 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/invoices/recurring', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const res = await fetch(
|
||||
schedule ? `/api/invoices/recurring/${schedule.id}` : '/api/invoices/recurring',
|
||||
{
|
||||
method: schedule ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
)
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || t('create_failed_fallback'))
|
||||
}
|
||||
toast({ title: t('created_title') })
|
||||
onCreated()
|
||||
toast({ title: schedule ? t('updated_title') : t('created_title') })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t('create_failed_title'),
|
||||
title: schedule ? t('update_failed_title') : t('create_failed_title'),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
variant: 'destructive',
|
||||
})
|
||||
@@ -164,6 +210,20 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
|
||||
const items = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
// Automatic sending requires a customer email; without one the cron would
|
||||
// just produce a monthly draft + warning. Block it at the source.
|
||||
const watchCustomerId = watch('customer_id')
|
||||
const selectedCustomer = customers.find((c) => c.id === watchCustomerId)
|
||||
const customerMissingEmail = !!selectedCustomer && !selectedCustomer.email
|
||||
|
||||
// The onValueChange guard on the customer select only fires on a manual
|
||||
// change. In edit mode a schedule can load with auto_send=true against a
|
||||
// customer who has since lost their email (customers load async, after the
|
||||
// form's defaultValues). Force auto_send off whenever the effective customer
|
||||
// has no email so a disabled-but-checked box can't PATCH auto_send=true.
|
||||
useEffect(() => {
|
||||
if (customerMissingEmail) setValue('auto_send', false)
|
||||
}, [customerMissingEmail, setValue])
|
||||
const subtotalRaw = items.reduce(
|
||||
(sum, it) => sum + (it.quantity || 0) * (it.unit_price || 0),
|
||||
0,
|
||||
@@ -196,7 +256,16 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
control={control}
|
||||
name="customer_id"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
// Switching to a customer without email while auto-send is
|
||||
// checked would create an unsendable schedule.
|
||||
const c = customers.find((x) => x.id === v)
|
||||
if (!c?.email) setValue('auto_send', false)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="customer_id">
|
||||
<SelectValue placeholder={t('customer_placeholder')} />
|
||||
</SelectTrigger>
|
||||
@@ -215,7 +284,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="day_of_month">{t('day_label')}</Label>
|
||||
<Input
|
||||
@@ -230,6 +299,33 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
{t('day_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="send_hour">{t('send_hour_label')}</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="send_hour"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(v) => field.onChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger id="send_hour" className="tabular-nums">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 24 }, (_, h) => h).map((h) => (
|
||||
<SelectItem key={h} value={String(h)} className="tabular-nums">
|
||||
{`${String(h).padStart(2, '0')}:00`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('send_hour_hint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="payment_terms_days">{t('payment_terms_label')}</Label>
|
||||
<Input
|
||||
@@ -275,6 +371,7 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
id="auto_send"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={customerMissingEmail}
|
||||
className="mt-1 h-4 w-4"
|
||||
/>
|
||||
)}
|
||||
@@ -286,6 +383,11 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('auto_send_description')}
|
||||
</p>
|
||||
{customerMissingEmail && (
|
||||
<p className="text-sm text-warning-foreground mt-1">
|
||||
{t('auto_send_missing_email')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,7 +505,13 @@ function NewRecurringScheduleForm({ onCreated, onCancel }: { onCreated: () => vo
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? t('creating') : t('create_schedule')}
|
||||
{schedule
|
||||
? isSubmitting
|
||||
? t('saving')
|
||||
: t('save_changes')
|
||||
: isSubmitting
|
||||
? t('creating')
|
||||
: t('create_schedule')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -517,14 +517,18 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
)
|
||||
}
|
||||
|
||||
// Not connected
|
||||
// Not connected. The momsdeklaration is already complete and can be filed
|
||||
// manually at skatteverket.se with no connection (see the manual-filing card
|
||||
// rendered above this panel). Connecting is an optional convenience for
|
||||
// submitting directly from Accounted, so frame it that way: users must not
|
||||
// read the connection as a prerequisite for producing or filing the report.
|
||||
if (!status?.connected) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileCheck className="h-5 w-5" />
|
||||
Skicka till Skatteverket
|
||||
Skicka direkt till Skatteverket (valfritt)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -535,7 +539,10 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Anslut till Skatteverket med BankID för att skicka momsdeklarationen direkt.
|
||||
Deklarationen är redan klar att lämnas in manuellt hos Skatteverket
|
||||
(se ovan). Vill du slippa skriva in siffrorna själv kan du ansluta med
|
||||
BankID och skicka den direkt härifrån, samt validera, spara utkast och
|
||||
signera.
|
||||
</p>
|
||||
<Button onClick={handleConnect} className="gap-2">
|
||||
<Link2 className="h-4 w-4" />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, Percent } from 'lucide-react'
|
||||
import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileDown, Percent } from 'lucide-react'
|
||||
import AgentSparkleButton from '@/components/agent/AgentSparkleButton'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
@@ -1042,6 +1042,56 @@ function ReportSectionTable({
|
||||
// (räkenskapsår); undefined for monthly/quarterly (calendar periods).
|
||||
const VatDrillContext = React.createContext<{ fiscalPeriodId?: string }>({})
|
||||
|
||||
// Skatteverket's e-service entry point for manually filing the momsdeklaration.
|
||||
// Manual filing needs no connection, so this link is the default path for
|
||||
// anyone who hasn't set up (or doesn't want) the direct-submission integration.
|
||||
const SKATTEVERKET_MOMS_URL =
|
||||
'https://www.skatteverket.se/foretag/etjansterochblanketter/etjanster/momsocharbetsgivardeklarationer'
|
||||
|
||||
/**
|
||||
* Manual-filing affordance shown directly under the calculated momsdeklaration.
|
||||
* The report is generated purely from the bookkeeping and never depends on the
|
||||
* Skatteverket connection, so every user (including core builds with the
|
||||
* skatteverket extension disabled) can file manually. This card makes that
|
||||
* explicit and offers the momsdeklaration as a PDF (in hela kronor) to read off
|
||||
* while filling in the form at skatteverket.se.
|
||||
*/
|
||||
function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lämna in din momsdeklaration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Deklarationen ovan är klar. Du behöver inte vara ansluten till
|
||||
Skatteverket för att lämna in den, du kan fylla i beloppen direkt hos
|
||||
Skatteverket.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ladda ner momsdeklarationen som PDF (belopp i hela kronor). Logga sedan
|
||||
in på skatteverket.se med BankID, öppna Moms- och
|
||||
arbetsgivardeklarationer och skriv in rutorna.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<a href={pdfHref} target="_blank" rel="noopener noreferrer">
|
||||
<FileDown className="h-4 w-4" />
|
||||
Ladda ner momsdeklaration (PDF)
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<a href={SKATTEVERKET_MOMS_URL} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Öppna skatteverket.se
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function VatDeclarationView() {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
@@ -1242,7 +1292,10 @@ export function VatDeclarationView() {
|
||||
<VatDrillContext.Provider value={{ fiscalPeriodId: isYearly ? fiscalPeriodId : undefined }}>
|
||||
<div className="space-y-4">
|
||||
<ReportExportMenu
|
||||
items={[{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` }]}
|
||||
items={[
|
||||
{ format: 'pdf', href: `/api/reports/vat-declaration/pdf?${vatQueryString()}` },
|
||||
{ format: 'xlsx', href: `/api/reports/vat-declaration/xlsx?${vatQueryString()}` },
|
||||
]}
|
||||
>
|
||||
<AgentSparkleButton
|
||||
intentId="vat.review"
|
||||
@@ -1539,6 +1592,10 @@ export function VatDeclarationView() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<VatManualFilingCard
|
||||
pdfHref={`/api/reports/vat-declaration/pdf?${vatQueryString()}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ interface EmployeeTaxCardProps {
|
||||
/** Income year the table/column applies to. Defaults to the current year. */
|
||||
year?: number
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Render the fields without the Card chrome (no border/header), for hosts
|
||||
* that lay their own section dividers around it (e.g. the compact
|
||||
* NewEmployeeDialog). The edit page keeps the default boxed rendering.
|
||||
*/
|
||||
flat?: boolean
|
||||
onChange: (value: EmployeeTaxValue) => void
|
||||
}
|
||||
|
||||
@@ -43,6 +49,7 @@ export default function EmployeeTaxCard({
|
||||
initial,
|
||||
year,
|
||||
disabled,
|
||||
flat,
|
||||
onChange,
|
||||
}: EmployeeTaxCardProps) {
|
||||
const t = useTranslations('salary_employee')
|
||||
@@ -88,12 +95,8 @@ export default function EmployeeTaxCard({
|
||||
})
|
||||
}, [fSkatt, sido, tableNumber, effectiveColumn, municipality, requiresTable])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
const body = (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="f_skatt_status">
|
||||
@@ -252,7 +255,19 @@ export default function EmployeeTaxCard({
|
||||
: t('tax_no_table_f_skatt')}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
)
|
||||
|
||||
if (flat) {
|
||||
return <div className="space-y-4">{body}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">{body}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,24 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Save } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
validateEmployeeBankAccount,
|
||||
isValidClearing,
|
||||
isValidAccount,
|
||||
normalizeBankNumber,
|
||||
lookupBankByClearing,
|
||||
checkEmployeeAccountChecksum,
|
||||
BANK_ISSUE_MESSAGES_SV,
|
||||
BANK_CHECKSUM_WARNING_SV,
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
@@ -22,6 +32,35 @@ function RequiredMark() {
|
||||
return <span className="text-destructive ml-0.5">*</span>
|
||||
}
|
||||
|
||||
// Small caps section label; sections are separated by a hairline divider
|
||||
// instead of being boxed in their own cards (compact, Linear-style layout).
|
||||
const SECTION_HEADER = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'
|
||||
|
||||
/** Compact label + control stack shared by every field in the dialog. */
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
htmlFor?: string
|
||||
required?: boolean
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<Label htmlFor={htmlFor}>
|
||||
{label}
|
||||
{required && <RequiredMark />}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -32,13 +71,17 @@ interface Props {
|
||||
/**
|
||||
* "Ny anställd" as a modal: mirrors NewSupplierInvoiceDialog. The last
|
||||
* register entity (after customers/suppliers/articles) to move off a full
|
||||
* page. Card sections carry over from the old /salary/employees/new page.
|
||||
* page. Compact layout: borderless sections split by hairline dividers
|
||||
* (no per-section cards) with a sticky Spara bar, to keep scrolling short.
|
||||
*/
|
||||
export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Props) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
|
||||
// Override the primitive's own padding + whole-dialog scroll: this
|
||||
// dialog is a fixed header + scrolling body + solid footer (flex
|
||||
// column), so the footer never overlaps scrolling content.
|
||||
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
// A half-typed employee must survive an accidental backdrop click or
|
||||
// a stray Escape (the municipality combobox and dimension pickers
|
||||
// portal outside the dialog). Closing is explicit: the header X or
|
||||
@@ -47,7 +90,7 @@ export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Pro
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogHeader className="border-b border-border px-6 pb-4 pt-6">
|
||||
<DialogTitle>Ny anställd</DialogTitle>
|
||||
</DialogHeader>
|
||||
<NewEmployeeForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
|
||||
@@ -65,6 +108,11 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
const [salaryType, setSalaryType] = useState('monthly')
|
||||
const [personnummer, setPersonnummer] = useState('')
|
||||
const [vacationRule, setVacationRule] = useState('procentregeln')
|
||||
const [clearing, setClearing] = useState('')
|
||||
const [account, setAccount] = useState('')
|
||||
// Suppress the (soft) check-digit warning while the user is still typing in
|
||||
// the bank fields; only surface it once they move focus away.
|
||||
const [bankFocused, setBankFocused] = useState(false)
|
||||
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
|
||||
// employee's salary-cost lines at booking. The fields render only when
|
||||
// company_settings.dimensions_enabled: same UI gate as the voucher form.
|
||||
@@ -97,6 +145,18 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
|
||||
// Block on structurally invalid bank details before hitting the server.
|
||||
const bankIssues = validateEmployeeBankAccount(clearing, account)
|
||||
if (bankIssues.length > 0) {
|
||||
toast({
|
||||
title: 'Kontrollera bankuppgifterna',
|
||||
description: bankIssues.map((i) => i.message).join('. '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
const form = new FormData(e.currentTarget)
|
||||
@@ -121,8 +181,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
address_line1: form.get('address_line1') as string || undefined,
|
||||
postal_code: form.get('postal_code') as string || undefined,
|
||||
city: form.get('city') as string || undefined,
|
||||
clearing_number: form.get('clearing_number') as string || undefined,
|
||||
bank_account_number: form.get('bank_account_number') as string || undefined,
|
||||
clearing_number: normalizeBankNumber(clearing) || undefined,
|
||||
bank_account_number: normalizeBankNumber(account) || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
// Always sent: {} means no default dimensions.
|
||||
@@ -150,27 +210,29 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const bankName = lookupBankByClearing(clearing)
|
||||
const showChecksumWarning =
|
||||
!bankFocused &&
|
||||
validateEmployeeBankAccount(clearing, account).length === 0 &&
|
||||
checkEmployeeAccountChecksum(clearing, account) === 'invalid'
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Personal info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Personuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="divide-y divide-border">
|
||||
{/* Personuppgifter */}
|
||||
<section className="space-y-3 py-4 first:pt-0">
|
||||
<h3 className={SECTION_HEADER}>Personuppgifter</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Förnamn" htmlFor="first_name" required>
|
||||
<Input id="first_name" name="first_name" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Efternamn" htmlFor="last_name" required>
|
||||
<Input id="last_name" name="last_name" required />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Personnummer" htmlFor="personnummer" required>
|
||||
<Input
|
||||
id="personnummer"
|
||||
name="personnummer"
|
||||
@@ -180,53 +242,37 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
value={personnummer}
|
||||
onChange={(e) => setPersonnummer(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input id="email" name="email" type="email" />
|
||||
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="E-post" htmlFor="email">
|
||||
<Input id="email" name="email" type="email" placeholder="Krävs för lönebesked" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input id="phone" name="phone" className="max-w-xs" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Field label="Telefon" htmlFor="phone" className="max-w-xs">
|
||||
<Input id="phone" name="phone" />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Adress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
{/* Adress */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Adress</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1fr_160px_1fr] gap-3">
|
||||
<Field label="Gatuadress" htmlFor="address_line1">
|
||||
<Input id="address_line1" name="address_line1" />
|
||||
</Field>
|
||||
<Field label="Postnummer" htmlFor="postal_code">
|
||||
<Input id="postal_code" name="postal_code" />
|
||||
</Field>
|
||||
<Field label="Ort" htmlFor="city">
|
||||
<Input id="city" name="city" />
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Employment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anställning</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_type">Typ</Label>
|
||||
{/* Anställning & lön */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Anställning & lön</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<Field label="Typ" htmlFor="employment_type">
|
||||
<Select value={employmentType} onValueChange={setEmploymentType}>
|
||||
<SelectTrigger id="employment_type">
|
||||
<SelectValue />
|
||||
@@ -237,32 +283,17 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
<SelectItem value="board_member">Styrelseledamot</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Anställningsdatum" htmlFor="employment_start" required>
|
||||
<Input id="employment_start" name="employment_start" type="date" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_end">Slutdatum</Label>
|
||||
</Field>
|
||||
<Field label="Slutdatum" htmlFor="employment_end">
|
||||
<Input id="employment_end" name="employment_end" type="date" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
|
||||
</Field>
|
||||
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Salary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Lön</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
|
||||
</Field>
|
||||
<Field label="Löneform" htmlFor="salary_type" required>
|
||||
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||
<SelectTrigger id="salary_type">
|
||||
<SelectValue />
|
||||
@@ -272,49 +303,30 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
<SelectItem value="hourly">Timlön</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Field>
|
||||
{salaryType === 'monthly' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
|
||||
<Field label="Månadslön (brutto)" htmlFor="monthly_salary" required>
|
||||
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
|
||||
</div>
|
||||
</Field>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
|
||||
<Field label="Timlön (SEK)" htmlFor="hourly_rate" required>
|
||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Default dimensions (kostnadsställe/projekt) */}
|
||||
{dimensionsEnabled && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Skatt */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Skatt</h3>
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} flat />
|
||||
</section>
|
||||
|
||||
{/* Tax */}
|
||||
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
|
||||
|
||||
{/* Vacation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semester</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||
{/* Semester */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Semester</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Semesterregel" htmlFor="vacation_rule">
|
||||
<Select value={vacationRule} onValueChange={setVacationRule}>
|
||||
<SelectTrigger id="vacation_rule">
|
||||
<SelectValue />
|
||||
@@ -336,38 +348,74 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp. Vanligt för tim- och visstidsanställda.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||
</Field>
|
||||
<Field label="Semesterdagar per år" htmlFor="vacation_days_per_year">
|
||||
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
|
||||
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Bankkonto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
<Input id="clearing_number" name="clearing_number" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_account_number">Kontonummer</Label>
|
||||
<Input id="bank_account_number" name="bank_account_number" />
|
||||
</div>
|
||||
{/* Kostnadsställe / Projekt (standard) */}
|
||||
{dimensionsEnabled && (
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Kostnadsställe / Projekt</h3>
|
||||
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Föreslås på lönekostnadsrader vid bokföring av lönekörningar.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Bankkonto */}
|
||||
<section className="space-y-3 py-4">
|
||||
<h3 className={SECTION_HEADER}>Bankkonto</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label="Clearingnummer" htmlFor="clearing_number">
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
value={clearing}
|
||||
onChange={(e) => setClearing(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
|
||||
/>
|
||||
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
|
||||
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.clearing_format}</p>
|
||||
) : bankName ? (
|
||||
<p className="text-xs text-muted-foreground">{bankName}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Krävs innan lönekörning</p>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Kontonummer" htmlFor="bank_account_number">
|
||||
<Input
|
||||
id="bank_account_number"
|
||||
name="bank_account_number"
|
||||
inputMode="numeric"
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
onFocus={() => setBankFocused(true)}
|
||||
onBlur={() => setBankFocused(false)}
|
||||
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
|
||||
/>
|
||||
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
|
||||
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.account_format}</p>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{showChecksumWarning && (
|
||||
<p className="text-xs text-warning-foreground">{BANK_CHECKSUM_WARNING_SV}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
{/* Solid footer outside the scroll area: always visible, never overlaps
|
||||
content (the body above scrolls independently). */}
|
||||
<div className="flex justify-end gap-3 border-t border-border bg-background px-6 py-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Avbryt
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user