Add standalone Castle accounting app
New standalone app at castle.html with its own Vite config, app shell, and bottom nav (Record, Transactions, Balance, Wallet, Settings). Reuses existing expenses and wallet modules with base module for a focused accounting experience. Features: - Expense submission via existing AddExpense dialog - Income submission placeholder (feature-flagged, pending backend) - Balance page with pending expense tracking - Expense drafts with receipt photo upload and BTC price snapshots - Cross-subdomain auth token relay via ?token= URL parameter - i18n support (en/fr/es) - PWA with offline support Also adds token relay to activities-app for consistent cross-app auth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b1e8534ca7
commit
d498030da0
17 changed files with 1391 additions and 0 deletions
19
castle.html
Normal file
19
castle.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<title>Castle — Accounting</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Castle">
|
||||
<meta name="description" content="Team accounting and expense management">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/accounting-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -12,6 +12,9 @@
|
|||
"dev:activities": "vite --host --config vite.activities.config.ts",
|
||||
"build:activities": "vue-tsc -b && vite build --config vite.activities.config.ts",
|
||||
"preview:activities": "vite preview --host --config vite.activities.config.ts",
|
||||
"dev:castle": "vite --host --config vite.castle.config.ts",
|
||||
"build:castle": "vue-tsc -b && vite build --config vite.castle.config.ts",
|
||||
"preview:castle": "vite preview --host --config vite.castle.config.ts",
|
||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||
"electron:package": "electron-builder",
|
||||
|
|
|
|||
84
src/accounting-app/App.vue
Normal file
84
src/accounting-app/App.vue
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
PlusCircle, List, Scale, Wallet, Settings,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
useTheme()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Bottom navigation tabs
|
||||
const bottomTabs = computed(() => [
|
||||
{ name: t('castle.nav.record'), icon: PlusCircle, path: '/record' },
|
||||
{ name: t('castle.nav.transactions'), icon: List, path: '/expenses/transactions' },
|
||||
{ name: t('castle.nav.balance'), icon: Scale, path: '/balance' },
|
||||
{ name: t('castle.nav.wallet'), icon: Wallet, path: '/wallet' },
|
||||
{ name: t('castle.nav.settings'), icon: Settings, path: '/settings' },
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(path: string): boolean {
|
||||
if (path === '/record') {
|
||||
return route.path === '/record'
|
||||
}
|
||||
if (path === '/expenses/transactions') {
|
||||
return route.path === '/expenses/transactions'
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Main content (with bottom padding for nav bar) -->
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<!-- Bottom navigation bar -->
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.path"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="isActiveTab(tab.path)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
73
src/accounting-app/app.config.ts
Normal file
73
src/accounting-app/app.config.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Castle accounting app configuration.
|
||||
* Only enables base + expenses + wallet modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
expenses: {
|
||||
name: 'expenses',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||
timeout: 30000
|
||||
},
|
||||
defaultCurrency: 'sats',
|
||||
maxExpenseAmount: 1000000,
|
||||
requireDescription: true
|
||||
}
|
||||
},
|
||||
wallet: {
|
||||
name: 'wallet',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
defaultReceiveAmount: 1000,
|
||||
maxReceiveAmount: 1000000,
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
},
|
||||
websocket: {
|
||||
enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false',
|
||||
reconnectDelay: 2000,
|
||||
maxReconnectAttempts: 3,
|
||||
fallbackToPolling: true,
|
||||
pollingInterval: 10000
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
181
src/accounting-app/app.ts
Normal file
181
src/accounting-app/app.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import expensesModule from '@/modules/expenses'
|
||||
import walletModule from '@/modules/wallet'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Accept an auth token from a URL parameter (e.g. ?token=xxx).
|
||||
* This allows the main app to link users directly into Castle
|
||||
* without requiring a separate login. The token is stored in
|
||||
* localStorage and the parameter is stripped from the URL.
|
||||
*/
|
||||
function acceptTokenFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
if (token) {
|
||||
localStorage.setItem('lnbits_access_token', token)
|
||||
// Also persist user data key so auth service picks it up
|
||||
params.delete('token')
|
||||
const clean = params.toString()
|
||||
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
console.log('[Castle] Auth token accepted from URL')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the standalone Castle accounting app
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Castle — Accounting App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Collect routes from enabled modules only
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...expensesModule.routes || [],
|
||||
...walletModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// Record page is the home page in standalone mode
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/record'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
// App-specific routes
|
||||
{
|
||||
path: '/record',
|
||||
name: 'record',
|
||||
component: () => import('./views/RecordPage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/balance',
|
||||
name: 'balance',
|
||||
component: () => import('./views/BalancePage.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('./views/SettingsPage.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
// Set default locale from env (user's saved preference takes priority via useStorage in i18n)
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
// Initialize plugin manager
|
||||
pluginManager.init(app, router)
|
||||
|
||||
// Register modules
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.expenses?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(expensesModule, appConfig.modules.expenses)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.wallet?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(walletModule, appConfig.modules.wallet)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Initialize auth
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
|
||||
// Auth guard — only redirect for routes that explicitly require auth
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
|
||||
if (requiresAuth && !auth.isAuthenticated.value) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.isAuthenticated.value) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// Global error handling
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Castle app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Castle app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Castle app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
163
src/accounting-app/composables/useExpenseDrafts.ts
Normal file
163
src/accounting-app/composables/useExpenseDrafts.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { computed } from 'vue'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import type { Account } from '@/modules/expenses/types'
|
||||
|
||||
/**
|
||||
* BTC price snapshot captured at draft creation time.
|
||||
* Preserves the exchange rate for reference since BTC is volatile.
|
||||
*/
|
||||
export interface BtcPriceSnapshot {
|
||||
price: number
|
||||
currency: string // e.g. "EUR", "USD"
|
||||
timestamp: string // ISO timestamp
|
||||
}
|
||||
|
||||
/**
|
||||
* Expense draft stored in localStorage.
|
||||
* Allows users to save partial entries and finish them later.
|
||||
*/
|
||||
export interface ExpenseDraft {
|
||||
id: string
|
||||
created_at: string
|
||||
type: 'expense' | 'income'
|
||||
account?: Account
|
||||
description?: string
|
||||
amount?: number
|
||||
currency?: string
|
||||
reference?: string
|
||||
is_equity?: boolean
|
||||
receipt_urls?: string[]
|
||||
btc_price_snapshot?: BtcPriceSnapshot
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'castle-expense-drafts'
|
||||
|
||||
/**
|
||||
* Composable for managing expense drafts in localStorage.
|
||||
* Drafts persist across sessions and are deleted when submitted.
|
||||
*/
|
||||
export function useExpenseDrafts() {
|
||||
const drafts = useStorage<ExpenseDraft[]>(STORAGE_KEY, [])
|
||||
|
||||
const draftCount = computed(() => drafts.value.length)
|
||||
const hasDrafts = computed(() => drafts.value.length > 0)
|
||||
|
||||
/**
|
||||
* Create a new draft. Returns the draft ID.
|
||||
*/
|
||||
function createDraft(partial: Partial<Omit<ExpenseDraft, 'id' | 'created_at'>>): string {
|
||||
const id = crypto.randomUUID()
|
||||
const draft: ExpenseDraft = {
|
||||
id,
|
||||
created_at: new Date().toISOString(),
|
||||
type: partial.type ?? 'expense',
|
||||
account: partial.account,
|
||||
description: partial.description,
|
||||
amount: partial.amount,
|
||||
currency: partial.currency,
|
||||
reference: partial.reference,
|
||||
is_equity: partial.is_equity,
|
||||
receipt_urls: partial.receipt_urls,
|
||||
btc_price_snapshot: partial.btc_price_snapshot,
|
||||
}
|
||||
drafts.value = [draft, ...drafts.value]
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing draft.
|
||||
*/
|
||||
function updateDraft(id: string, updates: Partial<Omit<ExpenseDraft, 'id' | 'created_at'>>) {
|
||||
const index = drafts.value.findIndex(d => d.id === id)
|
||||
if (index === -1) return
|
||||
|
||||
drafts.value = drafts.value.map((d, i) =>
|
||||
i === index ? { ...d, ...updates } : d
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a draft (e.g. after successful submission).
|
||||
*/
|
||||
function deleteDraft(id: string) {
|
||||
drafts.value = drafts.value.filter(d => d.id !== id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a draft by ID.
|
||||
*/
|
||||
function getDraft(id: string): ExpenseDraft | undefined {
|
||||
return drafts.value.find(d => d.id === id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a receipt URL to a draft.
|
||||
*/
|
||||
function addReceiptToDraft(id: string, url: string) {
|
||||
const draft = drafts.value.find(d => d.id === id)
|
||||
if (!draft) return
|
||||
|
||||
const urls = [...(draft.receipt_urls ?? []), url]
|
||||
updateDraft(id, { receipt_urls: urls })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a receipt URL from a draft.
|
||||
*/
|
||||
function removeReceiptFromDraft(id: string, url: string) {
|
||||
const draft = drafts.value.find(d => d.id === id)
|
||||
if (!draft) return
|
||||
|
||||
const urls = (draft.receipt_urls ?? []).filter(u => u !== url)
|
||||
updateDraft(id, { receipt_urls: urls })
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current BTC price from LNbits and return a snapshot.
|
||||
* Uses the /api/v1/conversion endpoint if available.
|
||||
*/
|
||||
async function captureBtcPrice(fiatCurrency: string = 'EUR'): Promise<BtcPriceSnapshot | undefined> {
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/conversion`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from_: 'sat', amount: 100_000_000, to: fiatCurrency }),
|
||||
signal: AbortSignal.timeout(10000)
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) return undefined
|
||||
|
||||
const data = await response.json()
|
||||
// data.sats gives us how many sats = 1 unit of fiat, or similar
|
||||
// The exact shape depends on LNbits version, but we want BTC price in fiat
|
||||
const btcPriceInFiat = data.amount ?? data.result
|
||||
if (typeof btcPriceInFiat !== 'number') return undefined
|
||||
|
||||
return {
|
||||
price: btcPriceInFiat,
|
||||
currency: fiatCurrency,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[useExpenseDrafts] Failed to capture BTC price:', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
drafts,
|
||||
draftCount,
|
||||
hasDrafts,
|
||||
createDraft,
|
||||
updateDraft,
|
||||
deleteDraft,
|
||||
getDraft,
|
||||
addReceiptToDraft,
|
||||
removeReceiptFromDraft,
|
||||
captureBtcPrice,
|
||||
}
|
||||
}
|
||||
18
src/accounting-app/main.ts
Normal file
18
src/accounting-app/main.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
// PWA service worker with periodic updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Castle app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
52
src/accounting-app/views/AddIncome.vue
Normal file
52
src/accounting-app/views/AddIncome.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TrendingUp, Info } from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
interface Emits {
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="true" @update:open="(open) => !open && handleClose()">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<TrendingUp class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<span>{{ t('castle.income.title') }}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ t('castle.income.description') }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- Placeholder content -->
|
||||
<div class="flex flex-col items-center py-8 space-y-4">
|
||||
<div class="rounded-full bg-muted p-4">
|
||||
<Info class="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground text-center max-w-xs">
|
||||
{{ t('castle.income.notAvailable') }}
|
||||
</p>
|
||||
<Button variant="outline" @click="handleClose">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
196
src/accounting-app/views/BalancePage.vue
Normal file
196
src/accounting-app/views/BalancePage.vue
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { ExpensesAPI } from '@/modules/expenses/services/ExpensesAPI'
|
||||
import type { Transaction } from '@/modules/expenses/types'
|
||||
import { ArrowDown, ArrowUp, Clock, RefreshCw, Loader2, PieChart } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { user } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
|
||||
|
||||
const balance = ref<number | null>(null)
|
||||
const balanceCurrency = ref<string>('sats')
|
||||
const pendingTransactions = ref<Transaction[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
|
||||
const budgetsEnabled = computed(() => import.meta.env.VITE_CASTLE_BUDGETS_ENABLED === 'true')
|
||||
|
||||
const pendingCount = computed(() => pendingTransactions.value.length)
|
||||
|
||||
const pendingTotal = computed(() =>
|
||||
pendingTransactions.value.reduce((sum, tx) => sum + Math.abs(tx.amount), 0)
|
||||
)
|
||||
|
||||
const pendingFiatTotal = computed(() => {
|
||||
const withFiat = pendingTransactions.value.filter(tx => tx.fiat_amount != null)
|
||||
if (withFiat.length === 0) return null
|
||||
return withFiat.reduce((sum, tx) => sum + Math.abs(tx.fiat_amount!), 0)
|
||||
})
|
||||
|
||||
const pendingFiatCurrency = computed(() => {
|
||||
const tx = pendingTransactions.value.find(tx => tx.fiat_currency)
|
||||
return tx?.fiat_currency ?? null
|
||||
})
|
||||
|
||||
const balanceIsPositive = computed(() => (balance.value ?? 0) >= 0)
|
||||
|
||||
async function loadData() {
|
||||
if (!walletKey.value) return
|
||||
|
||||
try {
|
||||
// Fetch balance and transactions in parallel
|
||||
const [balanceData, txData] = await Promise.all([
|
||||
expensesAPI.getUserBalance(walletKey.value),
|
||||
expensesAPI.getUserTransactions(walletKey.value, { limit: 1000, days: 60 })
|
||||
])
|
||||
|
||||
balance.value = balanceData.balance
|
||||
balanceCurrency.value = balanceData.currency || 'sats'
|
||||
|
||||
// Filter for pending transactions (flag = '!')
|
||||
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
|
||||
} catch (error) {
|
||||
console.error('[BalancePage] Error loading data:', error)
|
||||
toast.error('Failed to load balance data')
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
isRefreshing.value = true
|
||||
await loadData()
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
isLoading.value = true
|
||||
await loadData()
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return Math.abs(amount).toLocaleString()
|
||||
}
|
||||
|
||||
function formatFiat(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Header with refresh -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{{ t('castle.balance.title') }}</h1>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:disabled="isRefreshing"
|
||||
@click="refresh"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isRefreshing }" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="isLoading" class="flex items-center justify-center py-16">
|
||||
<Loader2 class="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Balance Hero -->
|
||||
<div class="rounded-xl border bg-card p-6 mb-6">
|
||||
<p class="text-sm text-muted-foreground mb-1">{{ t('castle.balance.netBalance') }}</p>
|
||||
|
||||
<div v-if="balance !== null" class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="balanceIsPositive ? ArrowDown : ArrowUp"
|
||||
class="w-5 h-5"
|
||||
:class="balanceIsPositive ? 'text-green-500' : 'text-red-500'"
|
||||
/>
|
||||
<span class="text-3xl font-bold text-foreground">
|
||||
{{ formatAmount(balance) }}
|
||||
</span>
|
||||
<span class="text-lg text-muted-foreground">{{ balanceCurrency }}</span>
|
||||
</div>
|
||||
<p class="text-sm" :class="balanceIsPositive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'">
|
||||
{{ balanceIsPositive ? t('castle.balance.owedToYou') : t('castle.balance.youOwe') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-muted-foreground">
|
||||
{{ t('castle.balance.noBalance') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Section -->
|
||||
<div v-if="pendingCount > 0" class="rounded-xl border bg-card p-5 mb-6">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Clock class="w-4 h-4 text-orange-500" />
|
||||
<h2 class="text-sm font-medium text-foreground">{{ t('castle.balance.pending') }}</h2>
|
||||
<Badge variant="secondary" class="text-xs">{{ pendingCount }}</Badge>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{{ t('castle.balance.pendingAmount', { amount: formatAmount(pendingTotal) + ' ' + balanceCurrency }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingFiatTotal !== null && pendingFiatCurrency" class="text-xs text-muted-foreground">
|
||||
~{{ formatFiat(pendingFiatTotal, pendingFiatCurrency) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending transaction list -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="tx in pendingTransactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between py-2 border-t border-border/50 first:border-t-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-foreground truncate">{{ tx.description }}</p>
|
||||
<p class="text-xs text-muted-foreground">{{ tx.date }}</p>
|
||||
</div>
|
||||
<div class="text-right shrink-0 ml-3">
|
||||
<p class="text-sm font-medium text-foreground">
|
||||
{{ formatAmount(tx.amount) }} {{ balanceCurrency }}
|
||||
</p>
|
||||
<p v-if="tx.fiat_amount && tx.fiat_currency" class="text-xs text-muted-foreground">
|
||||
{{ formatFiat(tx.fiat_amount, tx.fiat_currency) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Budget Section (feature-flagged) -->
|
||||
<div v-if="budgetsEnabled" class="rounded-xl border bg-card p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<PieChart class="w-4 h-4 text-primary" />
|
||||
<h2 class="text-sm font-medium text-foreground">Team Budget</h2>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Budget tracking will appear here once configured by your organization admin.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
174
src/accounting-app/views/RecordPage.vue
Normal file
174
src/accounting-app/views/RecordPage.vue
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { DollarSign, TrendingUp, Info, FileText, Trash2, Image as ImageIcon } from 'lucide-vue-next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import AddExpense from '@/modules/expenses/components/AddExpense.vue'
|
||||
import AddIncome from './AddIncome.vue'
|
||||
import { useExpenseDrafts, type ExpenseDraft } from '../composables/useExpenseDrafts'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { drafts, hasDrafts, deleteDraft } = useExpenseDrafts()
|
||||
|
||||
const showAddExpense = ref(false)
|
||||
const showAddIncome = ref(false)
|
||||
|
||||
const incomeEnabled = computed(() => import.meta.env.VITE_CASTLE_INCOME_ENABLED === 'true')
|
||||
|
||||
function handleExpenseSubmitted() {
|
||||
// Could refresh balance or show notification
|
||||
}
|
||||
|
||||
function handleExpenseClosed() {
|
||||
showAddExpense.value = false
|
||||
}
|
||||
|
||||
function handleIncomeClosed() {
|
||||
showAddIncome.value = false
|
||||
}
|
||||
|
||||
function openDraft(_draft: ExpenseDraft) {
|
||||
// TODO Phase 3: Pre-populate AddExpense with draft data
|
||||
// For now, just open the expense dialog
|
||||
showAddExpense.value = true
|
||||
}
|
||||
|
||||
function handleDeleteDraft(id: string) {
|
||||
deleteDraft(id)
|
||||
}
|
||||
|
||||
function draftTimeAgo(isoDate: string) {
|
||||
return useTimeAgo(new Date(isoDate)).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('castle.record.title') }}</h1>
|
||||
|
||||
<!-- Action Cards -->
|
||||
<div class="grid gap-4">
|
||||
<!-- Add Expense Card -->
|
||||
<button
|
||||
class="flex items-start gap-4 p-5 rounded-xl border bg-card text-left transition-colors hover:bg-accent/50 active:bg-accent/70"
|
||||
@click="showAddExpense = true"
|
||||
>
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 shrink-0">
|
||||
<DollarSign class="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('castle.record.addExpense') }}</h2>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('castle.record.addExpenseDescription') }}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Add Income Card -->
|
||||
<button
|
||||
class="flex items-start gap-4 p-5 rounded-xl border bg-card text-left transition-colors"
|
||||
:class="incomeEnabled
|
||||
? 'hover:bg-accent/50 active:bg-accent/70'
|
||||
: 'opacity-60 cursor-not-allowed'"
|
||||
:disabled="!incomeEnabled"
|
||||
@click="incomeEnabled && (showAddIncome = true)"
|
||||
>
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/20 shrink-0">
|
||||
<TrendingUp class="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-lg font-semibold text-foreground">{{ t('castle.record.addIncome') }}</h2>
|
||||
<Badge v-if="!incomeEnabled" variant="secondary" class="text-xs">
|
||||
{{ t('castle.record.comingSoon') }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-0.5">{{ t('castle.record.addIncomeDescription') }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info hint when income is disabled -->
|
||||
<div v-if="!incomeEnabled" class="mt-4 flex items-start gap-2 p-3 rounded-lg bg-muted/50">
|
||||
<Info class="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p class="text-xs text-muted-foreground">{{ t('castle.income.notAvailable') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Drafts Section -->
|
||||
<template v-if="hasDrafts">
|
||||
<Separator class="my-6" />
|
||||
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
{{ t('castle.record.drafts') }}
|
||||
<Badge variant="secondary" class="ml-1 text-xs">{{ drafts.length }}</Badge>
|
||||
</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="draft in drafts"
|
||||
:key="draft.id"
|
||||
class="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
@click="openDraft(draft)"
|
||||
>
|
||||
<!-- Receipt thumbnail or icon -->
|
||||
<div class="w-10 h-10 rounded-md bg-muted flex items-center justify-center shrink-0 overflow-hidden">
|
||||
<img
|
||||
v-if="draft.receipt_urls?.length"
|
||||
:src="draft.receipt_urls[0]"
|
||||
alt="Receipt"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<FileText v-else class="w-5 h-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<!-- Draft info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm text-foreground truncate">
|
||||
{{ draft.description || draft.account?.name || 'Untitled draft' }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ t('castle.record.draftAge', { time: draftTimeAgo(draft.created_at) }) }}</span>
|
||||
<span v-if="draft.amount">
|
||||
· {{ draft.amount }} {{ draft.currency || 'sats' }}
|
||||
</span>
|
||||
<span v-if="draft.receipt_urls?.length" class="flex items-center gap-0.5">
|
||||
· <ImageIcon class="w-3 h-3" /> {{ draft.receipt_urls.length }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- BTC price snapshot indicator -->
|
||||
<p v-if="draft.btc_price_snapshot" class="text-xs text-muted-foreground/70 mt-0.5">
|
||||
BTC @ {{ draft.btc_price_snapshot.price.toLocaleString() }} {{ draft.btc_price_snapshot.currency }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete button -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
@click.stop="handleDeleteDraft(draft.id)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Expense Dialog -->
|
||||
<AddExpense
|
||||
v-if="showAddExpense"
|
||||
@close="handleExpenseClosed"
|
||||
@expense-submitted="handleExpenseSubmitted"
|
||||
@action-complete="handleExpenseClosed"
|
||||
/>
|
||||
|
||||
<!-- Add Income Dialog -->
|
||||
<AddIncome
|
||||
v-if="showAddIncome"
|
||||
@close="handleIncomeClosed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
95
src/accounting-app/views/SettingsPage.vue
Normal file
95
src/accounting-app/views/SettingsPage.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Sun, Moon, LogIn, LogOut } from 'lucide-vue-next'
|
||||
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const languages: { code: AvailableLocale; label: string }[] = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'fr', label: 'Fran\u00e7ais' },
|
||||
{ code: 'es', label: 'Espa\u00f1ol' },
|
||||
]
|
||||
|
||||
const isAuthenticated = computed(() => auth.isAuthenticated.value)
|
||||
const userPubkey = computed(() => auth.currentUser.value?.pubkey)
|
||||
|
||||
function toggleTheme() {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
function setLanguage(lang: AvailableLocale) {
|
||||
changeLocale(lang)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<h1 class="text-2xl font-bold text-foreground mb-6">{{ t('castle.settings.title') }}</h1>
|
||||
|
||||
<!-- Account -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.account') }}</h2>
|
||||
<div v-if="isAuthenticated" class="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<p class="text-sm text-foreground font-mono truncate">
|
||||
{{ userPubkey }}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" class="w-full gap-2" @click="handleLogout">
|
||||
<LogOut class="w-4 h-4" />
|
||||
{{ t('castle.settings.logOut') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-sm text-muted-foreground mb-3">
|
||||
{{ t('castle.settings.loginPrompt') }}
|
||||
</p>
|
||||
<Button size="sm" class="w-full gap-2" @click="$router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
{{ t('castle.settings.logIn') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<!-- Appearance -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.appearance') }}</h2>
|
||||
<div class="flex items-center justify-between bg-muted/50 rounded-lg p-4">
|
||||
<span class="text-sm text-foreground">{{ t('castle.settings.theme') }}</span>
|
||||
<Button variant="outline" size="icon" class="h-8 w-8" @click="toggleTheme">
|
||||
<Sun v-if="theme === 'dark'" class="w-4 h-4" />
|
||||
<Moon v-else class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<!-- Language -->
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-sm font-medium text-muted-foreground uppercase tracking-wide">{{ t('castle.settings.language') }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:variant="locale === lang.code ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
@click="setLanguage(lang.code)"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -14,12 +14,33 @@ import App from './App.vue'
|
|||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Accept an auth token from a URL parameter (e.g. ?token=xxx).
|
||||
* Allows the main app to link users directly into this standalone
|
||||
* app without requiring a separate login.
|
||||
*/
|
||||
function acceptTokenFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
if (token) {
|
||||
localStorage.setItem('lnbits_access_token', token)
|
||||
params.delete('token')
|
||||
const clean = params.toString()
|
||||
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
console.log('[Sortir] Auth token accepted from URL')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the standalone activities app
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('🚀 Starting Sortir — Activities App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl()
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Collect routes from enabled modules only
|
||||
|
|
|
|||
|
|
@ -119,6 +119,55 @@ const messages: LocaleMessages = {
|
|||
language: 'Language',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
nav: {
|
||||
record: 'Record',
|
||||
transactions: 'Transactions',
|
||||
balance: 'Balance',
|
||||
wallet: 'Wallet',
|
||||
settings: 'Settings',
|
||||
},
|
||||
record: {
|
||||
title: 'Record',
|
||||
addExpense: 'Add Expense',
|
||||
addExpenseDescription: 'Submit an expense for approval',
|
||||
addIncome: 'Add Income',
|
||||
addIncomeDescription: 'Record income or revenue received',
|
||||
comingSoon: 'Coming Soon',
|
||||
drafts: 'Drafts',
|
||||
noDrafts: 'No saved drafts',
|
||||
draftAge: 'Saved {time}',
|
||||
saveDraft: 'Save Draft',
|
||||
deleteDraft: 'Delete Draft',
|
||||
},
|
||||
balance: {
|
||||
title: 'Balance',
|
||||
netBalance: 'Net Balance',
|
||||
pending: 'Pending',
|
||||
pendingExpenses: '{count} pending expense | {count} pending expenses',
|
||||
pendingAmount: '{amount} pending approval',
|
||||
noBalance: 'No balance data available',
|
||||
owedToYou: 'Owed to you',
|
||||
youOwe: 'You owe',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
account: 'Account',
|
||||
loginPrompt: 'Log in to record expenses and view your balance.',
|
||||
logIn: 'Log in',
|
||||
logOut: 'Log out',
|
||||
appearance: 'Appearance',
|
||||
theme: 'Theme',
|
||||
language: 'Language',
|
||||
},
|
||||
income: {
|
||||
title: 'Add Income',
|
||||
description: 'Submit income for the organization',
|
||||
selectAccount: 'Select the revenue account',
|
||||
submitIncome: 'Submit Income',
|
||||
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -119,6 +119,55 @@ const messages: LocaleMessages = {
|
|||
language: 'Idioma',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
nav: {
|
||||
record: 'Registrar',
|
||||
transactions: 'Transacciones',
|
||||
balance: 'Saldo',
|
||||
wallet: 'Billetera',
|
||||
settings: 'Ajustes',
|
||||
},
|
||||
record: {
|
||||
title: 'Registrar',
|
||||
addExpense: 'A\u00f1adir gasto',
|
||||
addExpenseDescription: 'Enviar un gasto para aprobaci\u00f3n',
|
||||
addIncome: 'A\u00f1adir ingreso',
|
||||
addIncomeDescription: 'Registrar un ingreso recibido',
|
||||
comingSoon: 'Pr\u00f3ximamente',
|
||||
drafts: 'Borradores',
|
||||
noDrafts: 'Sin borradores',
|
||||
draftAge: 'Guardado {time}',
|
||||
saveDraft: 'Guardar borrador',
|
||||
deleteDraft: 'Eliminar borrador',
|
||||
},
|
||||
balance: {
|
||||
title: 'Saldo',
|
||||
netBalance: 'Saldo neto',
|
||||
pending: 'Pendiente',
|
||||
pendingExpenses: '{count} gasto pendiente | {count} gastos pendientes',
|
||||
pendingAmount: '{amount} pendiente de aprobaci\u00f3n',
|
||||
noBalance: 'No hay datos de saldo disponibles',
|
||||
owedToYou: 'Te deben',
|
||||
youOwe: 'Debes',
|
||||
},
|
||||
settings: {
|
||||
title: 'Ajustes',
|
||||
account: 'Cuenta',
|
||||
loginPrompt: 'Inicia sesi\u00f3n para registrar gastos y ver tu saldo.',
|
||||
logIn: 'Iniciar sesi\u00f3n',
|
||||
logOut: 'Cerrar sesi\u00f3n',
|
||||
appearance: 'Apariencia',
|
||||
theme: 'Tema',
|
||||
language: 'Idioma',
|
||||
},
|
||||
income: {
|
||||
title: 'A\u00f1adir ingreso',
|
||||
description: 'Enviar un ingreso para la organizaci\u00f3n',
|
||||
selectAccount: 'Seleccionar la cuenta de ingresos',
|
||||
submitIncome: 'Enviar ingreso',
|
||||
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -119,6 +119,55 @@ const messages: LocaleMessages = {
|
|||
language: 'Langue',
|
||||
},
|
||||
},
|
||||
castle: {
|
||||
nav: {
|
||||
record: 'Saisir',
|
||||
transactions: 'Transactions',
|
||||
balance: 'Solde',
|
||||
wallet: 'Portefeuille',
|
||||
settings: 'Param\u00e8tres',
|
||||
},
|
||||
record: {
|
||||
title: 'Saisir',
|
||||
addExpense: 'Ajouter une d\u00e9pense',
|
||||
addExpenseDescription: 'Soumettre une d\u00e9pense pour approbation',
|
||||
addIncome: 'Ajouter un revenu',
|
||||
addIncomeDescription: 'Enregistrer un revenu re\u00e7u',
|
||||
comingSoon: 'Bient\u00f4t disponible',
|
||||
drafts: 'Brouillons',
|
||||
noDrafts: 'Aucun brouillon',
|
||||
draftAge: 'Enregistr\u00e9 {time}',
|
||||
saveDraft: 'Enregistrer le brouillon',
|
||||
deleteDraft: 'Supprimer le brouillon',
|
||||
},
|
||||
balance: {
|
||||
title: 'Solde',
|
||||
netBalance: 'Solde net',
|
||||
pending: 'En attente',
|
||||
pendingExpenses: '{count} d\u00e9pense en attente | {count} d\u00e9penses en attente',
|
||||
pendingAmount: '{amount} en attente d\u2019approbation',
|
||||
noBalance: 'Aucune donn\u00e9e de solde disponible',
|
||||
owedToYou: 'D\u00fb',
|
||||
youOwe: 'Vous devez',
|
||||
},
|
||||
settings: {
|
||||
title: 'Param\u00e8tres',
|
||||
account: 'Compte',
|
||||
loginPrompt: 'Connectez-vous pour saisir des d\u00e9penses et voir votre solde.',
|
||||
logIn: 'Se connecter',
|
||||
logOut: 'Se d\u00e9connecter',
|
||||
appearance: 'Apparence',
|
||||
theme: 'Th\u00e8me',
|
||||
language: 'Langue',
|
||||
},
|
||||
income: {
|
||||
title: 'Ajouter un revenu',
|
||||
description: 'Soumettre un revenu pour l\u2019organisation',
|
||||
selectAccount: 'S\u00e9lectionner le compte de revenus',
|
||||
submitIncome: 'Soumettre le revenu',
|
||||
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,56 @@ export interface LocaleMessages {
|
|||
language: string
|
||||
}
|
||||
}
|
||||
// Castle accounting module
|
||||
castle?: {
|
||||
nav: {
|
||||
record: string
|
||||
transactions: string
|
||||
balance: string
|
||||
wallet: string
|
||||
settings: string
|
||||
}
|
||||
record: {
|
||||
title: string
|
||||
addExpense: string
|
||||
addExpenseDescription: string
|
||||
addIncome: string
|
||||
addIncomeDescription: string
|
||||
comingSoon: string
|
||||
drafts: string
|
||||
noDrafts: string
|
||||
draftAge: string
|
||||
saveDraft: string
|
||||
deleteDraft: string
|
||||
}
|
||||
balance: {
|
||||
title: string
|
||||
netBalance: string
|
||||
pending: string
|
||||
pendingExpenses: string
|
||||
pendingAmount: string
|
||||
noBalance: string
|
||||
owedToYou: string
|
||||
youOwe: string
|
||||
}
|
||||
settings: {
|
||||
title: string
|
||||
account: string
|
||||
loginPrompt: string
|
||||
logIn: string
|
||||
logOut: string
|
||||
appearance: string
|
||||
theme: string
|
||||
language: string
|
||||
}
|
||||
income: {
|
||||
title: string
|
||||
description: string
|
||||
selectAccount: string
|
||||
submitIncome: string
|
||||
notAvailable: string
|
||||
}
|
||||
}
|
||||
// Add date/time formats
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
|
|
|
|||
115
vite.castle.config.ts
Normal file
115
vite.castle.config.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to castle.html
|
||||
* (SPA fallback for the standalone Castle accounting app entry point)
|
||||
*/
|
||||
function castleHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'castle-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Rewrite all non-asset requests to castle.html
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!req.url.includes('.') // skip files with extensions
|
||||
) {
|
||||
req.url = '/castle.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Castle accounting app.
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [
|
||||
castleHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Castle — Team Accounting',
|
||||
short_name: 'Castle',
|
||||
description: 'Team accounting and expense management',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
id: 'castle-accounting',
|
||||
categories: ['finance', 'business', 'productivity'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-castle/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// CRITICAL: Remap @/app.config to the castle app's config
|
||||
// ExpensesAPI and other modules import from @/app.config directly
|
||||
'@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-castle',
|
||||
rollupOptions: {
|
||||
input: 'castle.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue