mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(miniapp): refine UI and add utility bill management
- Fix collapsible padding and button spacing - Add subtotal to balance card - Add utility bill management for admins - Fix lints and type checks across the monorepo - Implement rejectPendingHouseholdMember in repository and service
This commit is contained in:
@@ -201,6 +201,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
],
|
],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
|
|||||||
: [],
|
: [],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async () => {
|
updateHouseholdDefaultLocale: async () => {
|
||||||
throw new Error('not implemented')
|
throw new Error('not implemented')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
|
|||||||
],
|
],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async () => household,
|
updateHouseholdDefaultLocale: async () => household,
|
||||||
updateMemberPreferredLocale: async () => null,
|
updateMemberPreferredLocale: async () => null,
|
||||||
updateHouseholdMemberDisplayName: async () => null,
|
updateHouseholdMemberDisplayName: async () => null,
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
},
|
},
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async () => {
|
updateHouseholdDefaultLocale: async () => {
|
||||||
throw new Error('not implemented')
|
throw new Error('not implemented')
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -520,6 +520,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
members.set(key, member)
|
members.set(key, member)
|
||||||
return member
|
return member
|
||||||
},
|
},
|
||||||
|
async rejectPendingHouseholdMember() {
|
||||||
|
return false
|
||||||
|
},
|
||||||
async updateHouseholdDefaultLocale(householdId, locale) {
|
async updateHouseholdDefaultLocale(householdId, locale) {
|
||||||
const household = [...households.values()].find((entry) => entry.householdId === householdId)
|
const household = [...households.values()].find((entry) => entry.householdId === householdId)
|
||||||
if (!household) {
|
if (!household) {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { createMiniAppAuthHandler, createMiniAppJoinHandler } from './miniapp-au
|
|||||||
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
import { createMiniAppDashboardHandler } from './miniapp-dashboard'
|
||||||
import {
|
import {
|
||||||
createMiniAppApproveMemberHandler,
|
createMiniAppApproveMemberHandler,
|
||||||
|
createMiniAppRejectMemberHandler,
|
||||||
createMiniAppPendingMembersHandler,
|
createMiniAppPendingMembersHandler,
|
||||||
createMiniAppPromoteMemberHandler,
|
createMiniAppPromoteMemberHandler,
|
||||||
createMiniAppSettingsHandler,
|
createMiniAppSettingsHandler,
|
||||||
@@ -576,6 +577,15 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-admin')
|
logger: getLogger('miniapp-admin')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
miniAppRejectMember: householdOnboardingService
|
||||||
|
? createMiniAppRejectMemberHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
|
miniAppAdminService: miniAppAdminService!,
|
||||||
|
logger: getLogger('miniapp-admin')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
miniAppSettings: householdOnboardingService
|
miniAppSettings: householdOnboardingService
|
||||||
? createMiniAppSettingsHandler({
|
? createMiniAppSettingsHandler({
|
||||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createMiniAppApproveMemberHandler,
|
createMiniAppApproveMemberHandler,
|
||||||
|
createMiniAppRejectMemberHandler,
|
||||||
createMiniAppPendingMembersHandler,
|
createMiniAppPendingMembersHandler,
|
||||||
createMiniAppPromoteMemberHandler,
|
createMiniAppPromoteMemberHandler,
|
||||||
createMiniAppSettingsHandler,
|
createMiniAppSettingsHandler,
|
||||||
@@ -131,6 +132,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
rejectPendingHouseholdMember: async (input) => input.telegramUserId === '555777',
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
@@ -407,6 +409,60 @@ describe('createMiniAppApproveMemberHandler', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('createMiniAppRejectMemberHandler', () => {
|
||||||
|
test('rejects a pending member for an authenticated admin', async () => {
|
||||||
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const repository = onboardingRepository()
|
||||||
|
repository.listHouseholdMembersByTelegramUserId = async () => [
|
||||||
|
{
|
||||||
|
id: 'member-123456',
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
status: 'active',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const handler = createMiniAppRejectMemberHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository
|
||||||
|
}),
|
||||||
|
miniAppAdminService: createMiniAppAdminService(repository)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/admin/reject-member', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
|
id: 123456,
|
||||||
|
first_name: 'Stan',
|
||||||
|
username: 'stanislav',
|
||||||
|
language_code: 'ru'
|
||||||
|
}),
|
||||||
|
pendingTelegramUserId: '555777'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('createMiniAppSettingsHandler', () => {
|
describe('createMiniAppSettingsHandler', () => {
|
||||||
test('returns billing settings and admin members for an authenticated admin', async () => {
|
test('returns billing settings and admin members for an authenticated admin', async () => {
|
||||||
const authDate = Math.floor(Date.now() / 1000)
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
|||||||
@@ -1396,3 +1396,87 @@ export function createMiniAppApproveMemberHandler(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMiniAppRejectMemberHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
miniAppAdminService: MiniAppAdminService
|
||||||
|
logger?: Logger
|
||||||
|
}): {
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
} {
|
||||||
|
const sessionService = createMiniAppSessionService({
|
||||||
|
botToken: options.botToken,
|
||||||
|
onboardingService: options.onboardingService
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handler: async (request) => {
|
||||||
|
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return miniAppJsonResponse({ ok: true }, 204, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await readApprovalPayload(request)
|
||||||
|
|
||||||
|
const session = await sessionService.authenticate({
|
||||||
|
initData: payload.initData
|
||||||
|
})
|
||||||
|
if (!session) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Invalid Telegram init data' },
|
||||||
|
401,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!session.authorized ||
|
||||||
|
!session.member ||
|
||||||
|
session.member.status !== 'active' ||
|
||||||
|
!session.member.isAdmin
|
||||||
|
) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Admin access required for active household members' },
|
||||||
|
403,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await options.miniAppAdminService.rejectPendingMember({
|
||||||
|
householdId: session.member.householdId,
|
||||||
|
actorIsAdmin: session.member.isAdmin,
|
||||||
|
pendingTelegramUserId: payload.pendingTelegramUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
const status = result.reason === 'pending_not_found' ? 404 : 403
|
||||||
|
const error =
|
||||||
|
result.reason === 'pending_not_found'
|
||||||
|
? 'Pending member not found'
|
||||||
|
: 'Admin access required'
|
||||||
|
|
||||||
|
return miniAppJsonResponse({ ok: false, error }, status, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: true
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
pending = null
|
pending = null
|
||||||
return member
|
return member
|
||||||
},
|
},
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
],
|
],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
listHouseholdMembersByTelegramUserId: async () => [],
|
listHouseholdMembersByTelegramUserId: async () => [],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
},
|
},
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null,
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => {
|
updateHouseholdDefaultLocale: async (_householdId, locale) => {
|
||||||
household.defaultLocale = locale
|
household.defaultLocale = locale
|
||||||
for (const [id, member] of members.entries()) {
|
for (const [id, member] of members.entries()) {
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppRejectMember?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
miniAppSettings?:
|
miniAppSettings?:
|
||||||
| {
|
| {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -128,6 +134,12 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppAddPurchase?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
miniAppUpdatePurchase?:
|
miniAppUpdatePurchase?:
|
||||||
| {
|
| {
|
||||||
path?: string
|
path?: string
|
||||||
@@ -201,6 +213,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
|
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
|
||||||
const miniAppApproveMemberPath =
|
const miniAppApproveMemberPath =
|
||||||
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
|
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
|
||||||
|
const miniAppRejectMemberPath =
|
||||||
|
options.miniAppRejectMember?.path ?? '/api/miniapp/admin/reject-member'
|
||||||
const miniAppSettingsPath = options.miniAppSettings?.path ?? '/api/miniapp/admin/settings'
|
const miniAppSettingsPath = options.miniAppSettings?.path ?? '/api/miniapp/admin/settings'
|
||||||
const miniAppUpdateSettingsPath =
|
const miniAppUpdateSettingsPath =
|
||||||
options.miniAppUpdateSettings?.path ?? '/api/miniapp/admin/settings/update'
|
options.miniAppUpdateSettings?.path ?? '/api/miniapp/admin/settings/update'
|
||||||
@@ -231,6 +245,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update'
|
options.miniAppUpdateUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/update'
|
||||||
const miniAppDeleteUtilityBillPath =
|
const miniAppDeleteUtilityBillPath =
|
||||||
options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete'
|
options.miniAppDeleteUtilityBill?.path ?? '/api/miniapp/admin/utility-bills/delete'
|
||||||
|
const miniAppAddPurchasePath =
|
||||||
|
options.miniAppAddPurchase?.path ?? '/api/miniapp/admin/purchases/add'
|
||||||
const miniAppUpdatePurchasePath =
|
const miniAppUpdatePurchasePath =
|
||||||
options.miniAppUpdatePurchase?.path ?? '/api/miniapp/admin/purchases/update'
|
options.miniAppUpdatePurchase?.path ?? '/api/miniapp/admin/purchases/update'
|
||||||
const miniAppDeletePurchasePath =
|
const miniAppDeletePurchasePath =
|
||||||
@@ -274,6 +290,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppApproveMember.handler(request)
|
return await options.miniAppApproveMember.handler(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppRejectMember && url.pathname === miniAppRejectMemberPath) {
|
||||||
|
return await options.miniAppRejectMember.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.miniAppSettings && url.pathname === miniAppSettingsPath) {
|
if (options.miniAppSettings && url.pathname === miniAppSettingsPath) {
|
||||||
return await options.miniAppSettings.handler(request)
|
return await options.miniAppSettings.handler(request)
|
||||||
}
|
}
|
||||||
@@ -350,6 +370,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppDeleteUtilityBill.handler(request)
|
return await options.miniAppDeleteUtilityBill.handler(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppAddPurchase && url.pathname === miniAppAddPurchasePath) {
|
||||||
|
return await options.miniAppAddPurchase.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.miniAppUpdatePurchase && url.pathname === miniAppUpdatePurchasePath) {
|
if (options.miniAppUpdatePurchase && url.pathname === miniAppUpdatePurchasePath) {
|
||||||
return await options.miniAppUpdatePurchase.handler(request)
|
return await options.miniAppUpdatePurchase.handler(request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
"@solidjs/router": "0.15.4",
|
||||||
"@tanstack/solid-query": "5.90.23",
|
"@tanstack/solid-query": "5.90.23",
|
||||||
"@twa-dev/sdk": "8.0.2",
|
"@twa-dev/sdk": "8.0.2",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"lucide-solid": "0.577.0",
|
||||||
"solid-js": "^1.9.9",
|
"solid-js": "^1.9.9",
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +0,0 @@
|
|||||||
import { For } from 'solid-js'
|
|
||||||
|
|
||||||
import type { MiniAppDashboard } from '../../miniapp-api'
|
|
||||||
import { StatCard } from '../ui'
|
|
||||||
|
|
||||||
type SummaryItem = {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
dashboard: MiniAppDashboard
|
|
||||||
utilityTotalMajor: string
|
|
||||||
purchaseTotalMajor: string
|
|
||||||
labels: {
|
|
||||||
remaining: string
|
|
||||||
rent: string
|
|
||||||
utilities: string
|
|
||||||
purchases: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FinanceSummaryCards(props: Props) {
|
|
||||||
const items: SummaryItem[] = [
|
|
||||||
{
|
|
||||||
label: props.labels.remaining,
|
|
||||||
value: `${props.dashboard.totalRemainingMajor} ${props.dashboard.currency}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: props.labels.rent,
|
|
||||||
value: `${props.dashboard.rentDisplayAmountMajor} ${props.dashboard.currency}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: props.labels.utilities,
|
|
||||||
value: `${props.utilityTotalMajor} ${props.dashboard.currency}`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: props.labels.purchases,
|
|
||||||
value: `${props.purchaseTotalMajor} ${props.dashboard.currency}`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<For each={items}>
|
|
||||||
{(item) => (
|
|
||||||
<StatCard>
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<strong>{item.value}</strong>
|
|
||||||
</StatCard>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { For, Match, Switch } from 'solid-js'
|
|
||||||
|
|
||||||
import type { MiniAppDashboard } from '../../miniapp-api'
|
|
||||||
|
|
||||||
type MemberVisual = {
|
|
||||||
member: MiniAppDashboard['members'][number]
|
|
||||||
totalMinor: bigint
|
|
||||||
barWidthPercent: number
|
|
||||||
segments: {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
amountMajor: string
|
|
||||||
amountMinor: bigint
|
|
||||||
widthPercent: number
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PurchaseSlice = {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
amountMajor: string
|
|
||||||
color: string
|
|
||||||
percentage: number
|
|
||||||
dasharray: string
|
|
||||||
dashoffset: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
dashboard: MiniAppDashboard
|
|
||||||
memberVisuals: readonly MemberVisual[]
|
|
||||||
purchaseChart: {
|
|
||||||
totalMajor: string
|
|
||||||
slices: readonly PurchaseSlice[]
|
|
||||||
}
|
|
||||||
labels: {
|
|
||||||
financeVisualsTitle: string
|
|
||||||
financeVisualsBody: string
|
|
||||||
membersCount: string
|
|
||||||
purchaseInvestmentsTitle: string
|
|
||||||
purchaseInvestmentsBody: string
|
|
||||||
purchaseInvestmentsEmpty: string
|
|
||||||
purchaseTotalLabel: string
|
|
||||||
purchaseShareLabel: string
|
|
||||||
}
|
|
||||||
remainingClass: (member: MiniAppDashboard['members'][number]) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FinanceVisuals(props: Props) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<article class="balance-item balance-item--wide">
|
|
||||||
<header>
|
|
||||||
<strong>{props.labels.financeVisualsTitle}</strong>
|
|
||||||
<span>
|
|
||||||
{props.labels.membersCount}: {String(props.dashboard.members.length)}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<p>{props.labels.financeVisualsBody}</p>
|
|
||||||
<div class="member-visual-list">
|
|
||||||
<For each={props.memberVisuals}>
|
|
||||||
{(item) => (
|
|
||||||
<article class="member-visual-card">
|
|
||||||
<header>
|
|
||||||
<strong>{item.member.displayName}</strong>
|
|
||||||
<span class={`balance-status ${props.remainingClass(item.member)}`}>
|
|
||||||
{item.member.remainingMajor} {props.dashboard.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div class="member-visual-bar">
|
|
||||||
<div
|
|
||||||
class="member-visual-bar__track"
|
|
||||||
style={{ width: `${item.barWidthPercent}%` }}
|
|
||||||
>
|
|
||||||
<For each={item.segments}>
|
|
||||||
{(segment) => (
|
|
||||||
<span
|
|
||||||
class={`member-visual-bar__segment member-visual-bar__segment--${segment.key}`}
|
|
||||||
style={{ width: `${segment.widthPercent}%` }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="member-visual-meta">
|
|
||||||
<For each={item.segments}>
|
|
||||||
{(segment) => (
|
|
||||||
<span class={`member-visual-chip member-visual-chip--${segment.key}`}>
|
|
||||||
{segment.label}: {segment.amountMajor} {props.dashboard.currency}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="balance-item balance-item--wide">
|
|
||||||
<header>
|
|
||||||
<strong>{props.labels.purchaseInvestmentsTitle}</strong>
|
|
||||||
<span>
|
|
||||||
{props.labels.purchaseTotalLabel}: {props.purchaseChart.totalMajor}{' '}
|
|
||||||
{props.dashboard.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<p>{props.labels.purchaseInvestmentsBody}</p>
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.purchaseChart.slices.length === 0}>
|
|
||||||
<p>{props.labels.purchaseInvestmentsEmpty}</p>
|
|
||||||
</Match>
|
|
||||||
<Match when={props.purchaseChart.slices.length > 0}>
|
|
||||||
<div class="purchase-chart">
|
|
||||||
<div class="purchase-chart__figure">
|
|
||||||
<svg class="purchase-chart__donut" viewBox="0 0 120 120" aria-hidden="true">
|
|
||||||
<circle class="purchase-chart__ring" cx="60" cy="60" r="42" />
|
|
||||||
<For each={props.purchaseChart.slices}>
|
|
||||||
{(slice) => (
|
|
||||||
<circle
|
|
||||||
class="purchase-chart__slice"
|
|
||||||
cx="60"
|
|
||||||
cy="60"
|
|
||||||
r="42"
|
|
||||||
stroke={slice.color}
|
|
||||||
stroke-dasharray={slice.dasharray}
|
|
||||||
stroke-dashoffset={slice.dashoffset}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</svg>
|
|
||||||
<div class="purchase-chart__center">
|
|
||||||
<strong>{props.purchaseChart.totalMajor}</strong>
|
|
||||||
<small>{props.dashboard.currency}</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="purchase-chart__legend">
|
|
||||||
<For each={props.purchaseChart.slices}>
|
|
||||||
{(slice) => (
|
|
||||||
<article class="purchase-chart__legend-item">
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="purchase-chart__legend-swatch"
|
|
||||||
style={{ 'background-color': slice.color }}
|
|
||||||
/>
|
|
||||||
<strong>{slice.label}</strong>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
{slice.amountMajor} {props.dashboard.currency} ·{' '}
|
|
||||||
{props.labels.purchaseShareLabel} {slice.percentage}%
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</article>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import { Show } from 'solid-js'
|
|
||||||
|
|
||||||
import { cn } from '../../lib/cn'
|
|
||||||
import { formatCyclePeriod, formatFriendlyDate } from '../../lib/dates'
|
|
||||||
import { majorStringToMinor, sumMajorStrings } from '../../lib/money'
|
|
||||||
import type { MiniAppDashboard } from '../../miniapp-api'
|
|
||||||
import { MiniChip, StatCard } from '../ui'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
copy: Record<string, string | undefined>
|
|
||||||
locale: 'en' | 'ru'
|
|
||||||
dashboard: MiniAppDashboard
|
|
||||||
member: MiniAppDashboard['members'][number]
|
|
||||||
detail?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MemberBalanceCard(props: Props) {
|
|
||||||
const utilitiesAdjustedMajor = () =>
|
|
||||||
sumMajorStrings(props.member.utilityShareMajor, props.member.purchaseOffsetMajor)
|
|
||||||
|
|
||||||
const adjustmentClass = () => {
|
|
||||||
const value = majorStringToMinor(props.member.purchaseOffsetMajor)
|
|
||||||
|
|
||||||
if (value < 0n) {
|
|
||||||
return 'is-credit'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value > 0n) {
|
|
||||||
return 'is-due'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'is-settled'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
class={cn(
|
|
||||||
'balance-item',
|
|
||||||
'balance-item--accent',
|
|
||||||
'balance-spotlight',
|
|
||||||
props.detail && 'balance-spotlight--detail'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header class="balance-spotlight__header">
|
|
||||||
<div class="balance-spotlight__copy">
|
|
||||||
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
|
|
||||||
<Show when={props.copy.yourBalanceBody}>{(body) => <p>{body()}</p>}</Show>
|
|
||||||
</div>
|
|
||||||
<div class="balance-spotlight__hero">
|
|
||||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.member.remainingMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
<Show when={majorStringToMinor(props.member.paidMajor) > 0n}>
|
|
||||||
<small>
|
|
||||||
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
|
|
||||||
</small>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="balance-spotlight__stats">
|
|
||||||
<StatCard class="balance-spotlight__stat">
|
|
||||||
<span>{props.copy.currentCycleLabel ?? ''}</span>
|
|
||||||
<strong>{formatCyclePeriod(props.dashboard.period, props.locale)}</strong>
|
|
||||||
</StatCard>
|
|
||||||
<StatCard class="balance-spotlight__stat">
|
|
||||||
<span>{props.copy.paidLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.member.paidMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</StatCard>
|
|
||||||
<StatCard class="balance-spotlight__stat">
|
|
||||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.member.remainingMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</StatCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="balance-spotlight__rows">
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.shareRent ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.member.rentShareMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.member.utilityShareMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''}</span>
|
|
||||||
<strong class={`balance-status ${adjustmentClass()}`}>
|
|
||||||
{props.member.purchaseOffsetMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<Show when={props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'}>
|
|
||||||
<article class="balance-detail-row balance-detail-row--accent">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{utilitiesAdjustedMajor()} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={props.dashboard.rentSourceCurrency !== props.dashboard.currency}>
|
|
||||||
<section class="fx-panel">
|
|
||||||
<header class="fx-panel__header">
|
|
||||||
<strong>{props.copy.rentFxTitle ?? ''}</strong>
|
|
||||||
<Show when={props.dashboard.rentFxEffectiveDate}>
|
|
||||||
{(date) => (
|
|
||||||
<MiniChip muted>
|
|
||||||
{props.copy.fxEffectiveDateLabel ?? ''}:{' '}
|
|
||||||
{formatFriendlyDate(date(), props.locale)}
|
|
||||||
</MiniChip>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="fx-panel__grid">
|
|
||||||
<article class="fx-panel__cell">
|
|
||||||
<span>{props.copy.sourceAmountLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency}
|
|
||||||
</strong>
|
|
||||||
</article>
|
|
||||||
<article class="fx-panel__cell">
|
|
||||||
<span>{props.copy.settlementAmountLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency}
|
|
||||||
</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,45 @@
|
|||||||
import type { JSX } from 'solid-js'
|
import { useNavigate, useLocation } from '@solidjs/router'
|
||||||
|
import { Home, Wallet, BookOpen } from 'lucide-solid'
|
||||||
|
import { type JSX } from 'solid-js'
|
||||||
|
|
||||||
type TabItem<T extends string> = {
|
import { useI18n } from '../../contexts/i18n-context'
|
||||||
key: T
|
|
||||||
|
type TabItem = {
|
||||||
|
path: string
|
||||||
label: string
|
label: string
|
||||||
icon?: JSX.Element
|
icon: JSX.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props<T extends string> = {
|
/**
|
||||||
items: readonly TabItem<T>[]
|
* Bottom navigation bar with 3 tabs (Bug #6 fix: reduced to 3 tabs,
|
||||||
active: T
|
* settings moved to top bar gear icon).
|
||||||
onChange: (key: T) => void
|
*/
|
||||||
}
|
export function NavigationTabs(): JSX.Element {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { copy } = useI18n()
|
||||||
|
|
||||||
|
const tabs = (): TabItem[] => [
|
||||||
|
{ path: '/', label: copy().home, icon: <Home size={20} /> },
|
||||||
|
{ path: '/balances', label: copy().balances, icon: <Wallet size={20} /> },
|
||||||
|
{ path: '/ledger', label: copy().ledger, icon: <BookOpen size={20} /> }
|
||||||
|
]
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/'
|
||||||
|
return location.pathname.startsWith(path)
|
||||||
|
}
|
||||||
|
|
||||||
export function NavigationTabs<T extends string>(props: Props<T>): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<nav class="nav-grid">
|
<nav class="nav-grid">
|
||||||
{props.items.map((item) => (
|
{tabs().map((tab) => (
|
||||||
<button
|
<button
|
||||||
classList={{ 'is-active': props.active === item.key }}
|
classList={{ 'is-active': isActive(tab.path) }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => props.onChange(item.key)}
|
onClick={() => navigate(tab.path)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{tab.icon}
|
||||||
<span>{item.label}</span>
|
<span>{tab.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Card, MiniChip } from '../ui'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
displayName: string
|
|
||||||
roleLabel: string
|
|
||||||
statusSummary: string
|
|
||||||
modeBadge: string
|
|
||||||
localeBadge: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProfileCard(props: Props) {
|
|
||||||
return (
|
|
||||||
<Card class="profile-card" accent>
|
|
||||||
<header>
|
|
||||||
<strong>{props.displayName}</strong>
|
|
||||||
<span>{props.roleLabel}</span>
|
|
||||||
</header>
|
|
||||||
<p>{props.statusSummary}</p>
|
|
||||||
<div class="ledger-compact-card__meta">
|
|
||||||
<MiniChip>{props.modeBadge}</MiniChip>
|
|
||||||
<MiniChip muted>{props.localeBadge}</MiniChip>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
164
apps/miniapp/src/components/layout/shell.tsx
Normal file
164
apps/miniapp/src/components/layout/shell.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
import { Show, createSignal, type ParentProps } from 'solid-js'
|
||||||
|
import { Settings } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { useSession } from '../../contexts/session-context'
|
||||||
|
import { useI18n } from '../../contexts/i18n-context'
|
||||||
|
import { useDashboard } from '../../contexts/dashboard-context'
|
||||||
|
import { NavigationTabs } from './navigation-tabs'
|
||||||
|
import { Badge } from '../ui/badge'
|
||||||
|
import { Button, IconButton } from '../ui/button'
|
||||||
|
import { Modal } from '../ui/dialog'
|
||||||
|
|
||||||
|
export function AppShell(props: ParentProps) {
|
||||||
|
const { readySession } = useSession()
|
||||||
|
const { copy, locale, setLocale } = useI18n()
|
||||||
|
const { effectiveIsAdmin, testingRolePreview, setTestingRolePreview } = useDashboard()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false)
|
||||||
|
|
||||||
|
function memberStatusLabel(status: 'active' | 'away' | 'left') {
|
||||||
|
const labels = {
|
||||||
|
active: copy().memberStatusActive,
|
||||||
|
away: copy().memberStatusAway,
|
||||||
|
left: copy().memberStatusLeft
|
||||||
|
}
|
||||||
|
return labels[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
let tapCount = 0
|
||||||
|
let tapTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
function handleRoleChipTap() {
|
||||||
|
tapCount++
|
||||||
|
if (tapCount >= 5) {
|
||||||
|
setTestingSurfaceOpen(true)
|
||||||
|
tapCount = 0
|
||||||
|
}
|
||||||
|
clearTimeout(tapTimer)
|
||||||
|
tapTimer = setTimeout(() => {
|
||||||
|
tapCount = 0
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="shell">
|
||||||
|
{/* ── Top bar ──────────────────────────────────── */}
|
||||||
|
<section class="topbar">
|
||||||
|
<div class="topbar__copy">
|
||||||
|
<p class="eyebrow">{copy().appSubtitle}</p>
|
||||||
|
<h1>{readySession()?.member.householdName ?? copy().appTitle}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar__actions">
|
||||||
|
<div class="locale-switch locale-switch--compact">
|
||||||
|
<div class="locale-switch__buttons">
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'en' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale('en')}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'ru' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocale('ru')}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconButton label="Settings" onClick={() => navigate('/settings')}>
|
||||||
|
<Settings size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Context badges ───────────────────────────── */}
|
||||||
|
<section class="app-context-row">
|
||||||
|
<div class="app-context-meta">
|
||||||
|
<Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}>
|
||||||
|
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||||
|
</Badge>
|
||||||
|
<Show
|
||||||
|
when={readySession()?.member.isAdmin}
|
||||||
|
fallback={
|
||||||
|
<Badge variant="muted">
|
||||||
|
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button class="ui-badge ui-badge--muted" onClick={handleRoleChipTap}>
|
||||||
|
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Badge variant="muted">
|
||||||
|
{readySession()?.member.status
|
||||||
|
? memberStatusLabel(readySession()!.member.status)
|
||||||
|
: copy().memberStatusActive}
|
||||||
|
</Badge>
|
||||||
|
<Show when={testingRolePreview()}>
|
||||||
|
{(preview) => (
|
||||||
|
<Badge variant="accent">
|
||||||
|
{`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Route content ────────────────────────────── */}
|
||||||
|
<section class="content-stack">{props.children}</section>
|
||||||
|
|
||||||
|
{/* ── Bottom nav (Bug #6: 3 tabs, proper padding) */}
|
||||||
|
<div class="app-bottom-nav">
|
||||||
|
<NavigationTabs />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Modals at route/shell level (Bug #1/#2 fix) */}
|
||||||
|
<Modal
|
||||||
|
open={testingSurfaceOpen()}
|
||||||
|
title={copy().testingSurfaceTitle ?? ''}
|
||||||
|
description={copy().testingSurfaceBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setTestingSurfaceOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setTestingSurfaceOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setTestingRolePreview(null)}>
|
||||||
|
{copy().testingUseRealRoleAction ?? ''}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="testing-card">
|
||||||
|
<article class="testing-card__section">
|
||||||
|
<span>{copy().testingCurrentRoleLabel ?? ''}</span>
|
||||||
|
<strong>{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="testing-card__section">
|
||||||
|
<span>{copy().testingPreviewRoleLabel ?? ''}</span>
|
||||||
|
<strong>
|
||||||
|
{testingRolePreview()
|
||||||
|
? testingRolePreview() === 'admin'
|
||||||
|
? copy().adminTag
|
||||||
|
: copy().residentTag
|
||||||
|
: copy().testingUseRealRoleAction}
|
||||||
|
</strong>
|
||||||
|
</article>
|
||||||
|
<div class="testing-card__actions">
|
||||||
|
<Button variant="secondary" onClick={() => setTestingRolePreview('admin')}>
|
||||||
|
{copy().testingPreviewAdminAction ?? ''}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setTestingRolePreview('resident')}>
|
||||||
|
{copy().testingPreviewResidentAction ?? ''}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { Locale } from '../../i18n'
|
|
||||||
import { GlobeIcon } from '../ui'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
subtitle: string
|
|
||||||
title: string
|
|
||||||
languageLabel: string
|
|
||||||
locale: Locale
|
|
||||||
saving: boolean
|
|
||||||
onChange: (locale: Locale) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopBar(props: Props) {
|
|
||||||
return (
|
|
||||||
<section class="topbar">
|
|
||||||
<div class="topbar__copy">
|
|
||||||
<p class="eyebrow">{props.subtitle}</p>
|
|
||||||
<h1>{props.title}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="locale-switch locale-switch--compact">
|
|
||||||
<span class="locale-switch__label sr-only">{props.languageLabel}</span>
|
|
||||||
<div class="locale-switch__buttons">
|
|
||||||
<span class="locale-switch__icon" aria-hidden="true">
|
|
||||||
<GlobeIcon />
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
classList={{ 'is-active': props.locale === 'en' }}
|
|
||||||
type="button"
|
|
||||||
disabled={props.saving}
|
|
||||||
aria-label={`${props.languageLabel}: English`}
|
|
||||||
onClick={() => props.onChange('en')}
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
classList={{ 'is-active': props.locale === 'ru' }}
|
|
||||||
type="button"
|
|
||||||
disabled={props.saving}
|
|
||||||
aria-label={`${props.languageLabel}: Russian`}
|
|
||||||
onClick={() => props.onChange('ru')}
|
|
||||||
>
|
|
||||||
RU
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
26
apps/miniapp/src/components/ui/badge.tsx
Normal file
26
apps/miniapp/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ParentProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type BadgeProps = ParentProps<{
|
||||||
|
variant?: 'default' | 'muted' | 'accent' | 'danger'
|
||||||
|
class?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function Badge(props: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
'ui-badge',
|
||||||
|
{
|
||||||
|
'ui-badge--muted': props.variant === 'muted',
|
||||||
|
'ui-badge--accent': props.variant === 'accent',
|
||||||
|
'ui-badge--danger': props.variant === 'danger'
|
||||||
|
},
|
||||||
|
props.class
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
import type { JSX, ParentProps } from 'solid-js'
|
import { Show, type JSX, type ParentProps } from 'solid-js'
|
||||||
|
import { Loader2 } from 'lucide-solid'
|
||||||
|
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
@@ -11,10 +12,16 @@ const buttonVariants = cva('ui-button', {
|
|||||||
danger: 'ui-button--danger',
|
danger: 'ui-button--danger',
|
||||||
ghost: 'ui-button--ghost',
|
ghost: 'ui-button--ghost',
|
||||||
icon: 'ui-button--icon'
|
icon: 'ui-button--icon'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
sm: 'ui-button--sm',
|
||||||
|
md: '',
|
||||||
|
lg: 'ui-button--lg'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'secondary'
|
variant: 'secondary',
|
||||||
|
size: 'md'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -22,6 +29,7 @@ type ButtonProps = ParentProps<{
|
|||||||
type?: 'button' | 'submit' | 'reset'
|
type?: 'button' | 'submit' | 'reset'
|
||||||
class?: string
|
class?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
onClick?: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
|
||||||
}> &
|
}> &
|
||||||
VariantProps<typeof buttonVariants>
|
VariantProps<typeof buttonVariants>
|
||||||
@@ -30,10 +38,13 @@ export function Button(props: ButtonProps) {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={props.type ?? 'button'}
|
type={props.type ?? 'button'}
|
||||||
class={cn(buttonVariants({ variant: props.variant }), props.class)}
|
class={cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled || props.loading}
|
||||||
onClick={props.onClick}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
|
<Show when={props.loading}>
|
||||||
|
<Loader2 class="ui-button__spinner" size={16} />
|
||||||
|
</Show>
|
||||||
{props.children}
|
{props.children}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import type { ParentProps } from 'solid-js'
|
|||||||
|
|
||||||
import { cn } from '../../lib/cn'
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
export function Card(props: ParentProps<{ class?: string; accent?: boolean }>) {
|
export function Card(
|
||||||
|
props: ParentProps<{ class?: string; accent?: boolean; muted?: boolean; wide?: boolean }>
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<article class={cn('balance-item', props.accent && 'balance-item--accent', props.class)}>
|
<article
|
||||||
|
class={cn(
|
||||||
|
'ui-card',
|
||||||
|
props.accent && 'ui-card--accent',
|
||||||
|
props.muted && 'ui-card--muted',
|
||||||
|
props.wide && 'ui-card--wide',
|
||||||
|
props.class
|
||||||
|
)}
|
||||||
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
@@ -14,6 +24,7 @@ export function StatCard(props: ParentProps<{ class?: string }>) {
|
|||||||
return <article class={cn('stat-card', props.class)}>{props.children}</article>
|
return <article class={cn('stat-card', props.class)}>{props.children}</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use Badge component instead */
|
||||||
export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) {
|
export function MiniChip(props: ParentProps<{ muted?: boolean; class?: string }>) {
|
||||||
return (
|
return (
|
||||||
<span class={cn('mini-chip', props.muted && 'mini-chip--muted', props.class)}>
|
<span class={cn('mini-chip', props.muted && 'mini-chip--muted', props.class)}>
|
||||||
|
|||||||
32
apps/miniapp/src/components/ui/collapsible.tsx
Normal file
32
apps/miniapp/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as CollapsiblePrimitive from '@kobalte/core/collapsible'
|
||||||
|
import { Show, type ParentProps } from 'solid-js'
|
||||||
|
import { ChevronDown } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type CollapsibleProps = ParentProps<{
|
||||||
|
title: string
|
||||||
|
body?: string
|
||||||
|
defaultOpen?: boolean
|
||||||
|
class?: string
|
||||||
|
}>
|
||||||
|
|
||||||
|
export function Collapsible(props: CollapsibleProps) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Root
|
||||||
|
{...(props.defaultOpen !== undefined ? { defaultOpen: props.defaultOpen } : {})}
|
||||||
|
class={cn('ui-collapsible', props.class)}
|
||||||
|
>
|
||||||
|
<CollapsiblePrimitive.Trigger class="ui-collapsible__trigger">
|
||||||
|
<div class="ui-collapsible__copy">
|
||||||
|
<strong>{props.title}</strong>
|
||||||
|
<Show when={props.body}>{(body) => <p>{body()}</p>}</Show>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="ui-collapsible__chevron" size={18} />
|
||||||
|
</CollapsiblePrimitive.Trigger>
|
||||||
|
<CollapsiblePrimitive.Content class="ui-collapsible__content">
|
||||||
|
{props.children}
|
||||||
|
</CollapsiblePrimitive.Content>
|
||||||
|
</CollapsiblePrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,10 +11,10 @@ export function Field(
|
|||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<label class={cn('settings-field', props.wide && 'settings-field--wide', props.class)}>
|
<label class={cn('ui-field', props.wide && 'ui-field--wide', props.class)}>
|
||||||
<span>{props.label}</span>
|
<span class="ui-field__label">{props.label}</span>
|
||||||
{props.children}
|
{props.children}
|
||||||
<Show when={props.hint}>{(hint) => <small>{hint()}</small>}</Show>
|
<Show when={props.hint}>{(hint) => <small class="ui-field__hint">{hint()}</small>}</Show>
|
||||||
</label>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ export * from './card'
|
|||||||
export * from './dialog'
|
export * from './dialog'
|
||||||
export * from './field'
|
export * from './field'
|
||||||
export * from './icons'
|
export * from './icons'
|
||||||
|
export * from './input'
|
||||||
|
export * from './select'
|
||||||
|
export * from './toggle'
|
||||||
|
export * from './collapsible'
|
||||||
|
export * from './badge'
|
||||||
|
export * from './skeleton'
|
||||||
|
|||||||
65
apps/miniapp/src/components/ui/input.tsx
Normal file
65
apps/miniapp/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { JSX } from 'solid-js'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type InputProps = {
|
||||||
|
value?: string
|
||||||
|
placeholder?: string
|
||||||
|
type?: 'text' | 'number' | 'email'
|
||||||
|
min?: string | number
|
||||||
|
max?: string | number
|
||||||
|
step?: string | number
|
||||||
|
maxlength?: number
|
||||||
|
disabled?: boolean
|
||||||
|
invalid?: boolean
|
||||||
|
class?: string
|
||||||
|
style?: JSX.CSSProperties
|
||||||
|
list?: string
|
||||||
|
id?: string
|
||||||
|
onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent>
|
||||||
|
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input(props: InputProps) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={props.type ?? 'text'}
|
||||||
|
value={props.value ?? ''}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
min={props.min}
|
||||||
|
max={props.max}
|
||||||
|
step={props.step}
|
||||||
|
maxlength={props.maxlength}
|
||||||
|
disabled={props.disabled}
|
||||||
|
aria-invalid={props.invalid}
|
||||||
|
style={props.style}
|
||||||
|
list={props.list}
|
||||||
|
id={props.id}
|
||||||
|
class={cn('ui-input', props.class)}
|
||||||
|
onInput={props.onInput}
|
||||||
|
onChange={props.onChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea(props: {
|
||||||
|
value?: string
|
||||||
|
placeholder?: string
|
||||||
|
rows?: number
|
||||||
|
maxlength?: number
|
||||||
|
disabled?: boolean
|
||||||
|
class?: string
|
||||||
|
onInput?: JSX.EventHandlerUnion<HTMLTextAreaElement, InputEvent>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
value={props.value ?? ''}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
rows={props.rows ?? 4}
|
||||||
|
maxlength={props.maxlength}
|
||||||
|
disabled={props.disabled}
|
||||||
|
class={cn('ui-input ui-textarea', props.class)}
|
||||||
|
onInput={props.onInput}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
apps/miniapp/src/components/ui/select.tsx
Normal file
66
apps/miniapp/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import * as SelectPrimitive from '@kobalte/core/select'
|
||||||
|
import { Check, ChevronDown } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectProps = {
|
||||||
|
value?: string
|
||||||
|
options: readonly SelectOption[]
|
||||||
|
disabled?: boolean
|
||||||
|
class?: string
|
||||||
|
id?: string
|
||||||
|
ariaLabel: string
|
||||||
|
placeholder?: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select(props: SelectProps) {
|
||||||
|
const selectedOption = () =>
|
||||||
|
props.options.find((option) => option.value === (props.value ?? '')) ?? null
|
||||||
|
const optionalRootProps = {
|
||||||
|
...(props.disabled !== undefined ? { disabled: props.disabled } : {}),
|
||||||
|
...(props.id !== undefined ? { id: props.id } : {}),
|
||||||
|
...(props.placeholder !== undefined ? { placeholder: props.placeholder } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Root<SelectOption>
|
||||||
|
value={selectedOption()}
|
||||||
|
options={[...props.options]}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
onChange={(option) => props.onChange?.(option?.value ?? '')}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<SelectPrimitive.Item item={itemProps.item} class="ui-select__item">
|
||||||
|
<SelectPrimitive.ItemLabel class="ui-select__item-label">
|
||||||
|
{itemProps.item.rawValue.label}
|
||||||
|
</SelectPrimitive.ItemLabel>
|
||||||
|
<SelectPrimitive.ItemIndicator class="ui-select__item-indicator">
|
||||||
|
<Check size={14} />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)}
|
||||||
|
{...optionalRootProps}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.HiddenSelect />
|
||||||
|
<SelectPrimitive.Trigger class={cn('ui-select', props.class)} aria-label={props.ariaLabel}>
|
||||||
|
<SelectPrimitive.Value<SelectOption> class="ui-select__value">
|
||||||
|
{(state) => state.selectedOption()?.label ?? props.placeholder ?? ''}
|
||||||
|
</SelectPrimitive.Value>
|
||||||
|
<SelectPrimitive.Icon class="ui-select__icon">
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content class="ui-select__content">
|
||||||
|
<SelectPrimitive.Listbox class="ui-select__listbox" />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
</SelectPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
apps/miniapp/src/components/ui/skeleton.tsx
Normal file
19
apps/miniapp/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type SkeletonProps = {
|
||||||
|
class?: string
|
||||||
|
width?: string
|
||||||
|
height?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton(props: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={cn('ui-skeleton', props.class)}
|
||||||
|
style={{
|
||||||
|
width: props.width,
|
||||||
|
height: props.height
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
apps/miniapp/src/components/ui/toggle.tsx
Normal file
29
apps/miniapp/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as SwitchPrimitive from '@kobalte/core/switch'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type ToggleProps = {
|
||||||
|
checked: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
label?: string
|
||||||
|
class?: string
|
||||||
|
onChange: (checked: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle(props: ToggleProps) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
checked={props.checked}
|
||||||
|
{...(props.disabled !== undefined ? { disabled: props.disabled } : {})}
|
||||||
|
onChange={props.onChange}
|
||||||
|
class={cn('ui-toggle', props.class)}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Input />
|
||||||
|
<SwitchPrimitive.Control class="ui-toggle__track">
|
||||||
|
<SwitchPrimitive.Thumb class="ui-toggle__thumb" />
|
||||||
|
</SwitchPrimitive.Control>
|
||||||
|
{props.label && (
|
||||||
|
<SwitchPrimitive.Label class="ui-toggle__label">{props.label}</SwitchPrimitive.Label>
|
||||||
|
)}
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
357
apps/miniapp/src/contexts/dashboard-context.tsx
Normal file
357
apps/miniapp/src/contexts/dashboard-context.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
import { createContext, createMemo, createSignal, useContext, type ParentProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { majorStringToMinor, minorToMajorString } from '../lib/money'
|
||||||
|
import {
|
||||||
|
fetchDashboardQuery,
|
||||||
|
fetchAdminSettingsQuery,
|
||||||
|
fetchBillingCycleQuery,
|
||||||
|
fetchPendingMembersQuery
|
||||||
|
} from '../app/miniapp-queries'
|
||||||
|
import { absoluteMinor } from '../lib/ledger-helpers'
|
||||||
|
import type {
|
||||||
|
MiniAppAdminCycleState,
|
||||||
|
MiniAppAdminSettingsPayload,
|
||||||
|
MiniAppDashboard,
|
||||||
|
MiniAppPendingMember
|
||||||
|
} from '../miniapp-api'
|
||||||
|
import {
|
||||||
|
demoAdminSettings,
|
||||||
|
demoCycleState,
|
||||||
|
demoDashboard,
|
||||||
|
demoPendingMembers
|
||||||
|
} from '../demo/miniapp-demo'
|
||||||
|
import { useSession } from './session-context'
|
||||||
|
import { useI18n } from './i18n-context'
|
||||||
|
|
||||||
|
/* ── Types ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type TestingRolePreview = 'admin' | 'resident'
|
||||||
|
|
||||||
|
export type BillingFormState = {
|
||||||
|
householdName: string
|
||||||
|
settlementCurrency: 'USD' | 'GEL'
|
||||||
|
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
|
||||||
|
rentAmountMajor: string
|
||||||
|
rentCurrency: 'USD' | 'GEL'
|
||||||
|
rentDueDay: number
|
||||||
|
rentWarningDay: number
|
||||||
|
utilitiesDueDay: number
|
||||||
|
utilitiesReminderDay: number
|
||||||
|
timezone: string
|
||||||
|
assistantContext: string
|
||||||
|
assistantTone: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CycleFormState = {
|
||||||
|
period: string
|
||||||
|
rentCurrency: 'USD' | 'GEL'
|
||||||
|
utilityCurrency: 'USD' | 'GEL'
|
||||||
|
rentAmountMajor: string
|
||||||
|
utilityCategorySlug: string
|
||||||
|
utilityAmountMajor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartPalette = ['#3ecf8e', '#6fd3c0', '#94a8ff', '#f06a8d', '#f3d36f', '#7dc96d'] as const
|
||||||
|
|
||||||
|
type DashboardContextValue = {
|
||||||
|
dashboard: () => MiniAppDashboard | null
|
||||||
|
setDashboard: (
|
||||||
|
value: MiniAppDashboard | null | ((prev: MiniAppDashboard | null) => MiniAppDashboard | null)
|
||||||
|
) => void
|
||||||
|
adminSettings: () => MiniAppAdminSettingsPayload | null
|
||||||
|
setAdminSettings: (
|
||||||
|
value:
|
||||||
|
| MiniAppAdminSettingsPayload
|
||||||
|
| null
|
||||||
|
| ((prev: MiniAppAdminSettingsPayload | null) => MiniAppAdminSettingsPayload | null)
|
||||||
|
) => void
|
||||||
|
cycleState: () => MiniAppAdminCycleState | null
|
||||||
|
setCycleState: (
|
||||||
|
value:
|
||||||
|
| MiniAppAdminCycleState
|
||||||
|
| null
|
||||||
|
| ((prev: MiniAppAdminCycleState | null) => MiniAppAdminCycleState | null)
|
||||||
|
) => void
|
||||||
|
pendingMembers: () => readonly MiniAppPendingMember[]
|
||||||
|
setPendingMembers: (
|
||||||
|
value:
|
||||||
|
| readonly MiniAppPendingMember[]
|
||||||
|
| ((prev: readonly MiniAppPendingMember[]) => readonly MiniAppPendingMember[])
|
||||||
|
) => void
|
||||||
|
effectiveIsAdmin: () => boolean
|
||||||
|
currentMemberLine: () => MiniAppDashboard['members'][number] | null
|
||||||
|
purchaseLedger: () => MiniAppDashboard['ledger'][number][]
|
||||||
|
utilityLedger: () => MiniAppDashboard['ledger'][number][]
|
||||||
|
paymentLedger: () => MiniAppDashboard['ledger'][number][]
|
||||||
|
utilityTotalMajor: () => string
|
||||||
|
purchaseTotalMajor: () => string
|
||||||
|
memberBalanceVisuals: () => ReturnType<typeof computeMemberBalanceVisuals>
|
||||||
|
purchaseInvestmentChart: () => ReturnType<typeof computePurchaseInvestmentChart>
|
||||||
|
testingRolePreview: () => TestingRolePreview | null
|
||||||
|
setTestingRolePreview: (value: TestingRolePreview | null) => void
|
||||||
|
loadDashboardData: (initData: string, isAdmin: boolean) => Promise<void>
|
||||||
|
applyDemoState: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardContext = createContext<DashboardContextValue>()
|
||||||
|
|
||||||
|
/* ── Derived computations ───────────────────────────── */
|
||||||
|
|
||||||
|
function computeMemberBalanceVisuals(
|
||||||
|
data: MiniAppDashboard | null,
|
||||||
|
copyFn: () => { shareRent: string; shareUtilities: string; shareOffset: string }
|
||||||
|
) {
|
||||||
|
if (!data) return []
|
||||||
|
|
||||||
|
const copy = copyFn()
|
||||||
|
const totals = data.members.map((member) => {
|
||||||
|
const rentMinor = absoluteMinor(majorStringToMinor(member.rentShareMajor))
|
||||||
|
const utilityMinor = absoluteMinor(majorStringToMinor(member.utilityShareMajor))
|
||||||
|
const purchaseMinor = absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor))
|
||||||
|
|
||||||
|
return {
|
||||||
|
member,
|
||||||
|
totalMinor: rentMinor + utilityMinor + purchaseMinor,
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
key: 'rent',
|
||||||
|
label: copy.shareRent,
|
||||||
|
amountMajor: member.rentShareMajor,
|
||||||
|
amountMinor: rentMinor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'utilities',
|
||||||
|
label: copy.shareUtilities,
|
||||||
|
amountMajor: member.utilityShareMajor,
|
||||||
|
amountMinor: utilityMinor
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key:
|
||||||
|
majorStringToMinor(member.purchaseOffsetMajor) < 0n
|
||||||
|
? 'purchase-credit'
|
||||||
|
: 'purchase-debit',
|
||||||
|
label: copy.shareOffset,
|
||||||
|
amountMajor: member.purchaseOffsetMajor,
|
||||||
|
amountMinor: purchaseMinor
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxTotalMinor = totals.reduce(
|
||||||
|
(max, item) => (item.totalMinor > max ? item.totalMinor : max),
|
||||||
|
0n
|
||||||
|
)
|
||||||
|
|
||||||
|
return totals
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftRemaining = majorStringToMinor(left.member.remainingMajor)
|
||||||
|
const rightRemaining = majorStringToMinor(right.member.remainingMajor)
|
||||||
|
if (rightRemaining === leftRemaining) {
|
||||||
|
return left.member.displayName.localeCompare(right.member.displayName)
|
||||||
|
}
|
||||||
|
return rightRemaining > leftRemaining ? 1 : -1
|
||||||
|
})
|
||||||
|
.map((item) => ({
|
||||||
|
...item,
|
||||||
|
barWidthPercent:
|
||||||
|
maxTotalMinor > 0n ? (Number(item.totalMinor) / Number(maxTotalMinor)) * 100 : 0,
|
||||||
|
segments: item.segments.map((segment) => ({
|
||||||
|
...segment,
|
||||||
|
widthPercent:
|
||||||
|
item.totalMinor > 0n ? (Number(segment.amountMinor) / Number(item.totalMinor)) * 100 : 0
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePurchaseInvestmentChart(
|
||||||
|
data: MiniAppDashboard | null,
|
||||||
|
entries: MiniAppDashboard['ledger'][number][],
|
||||||
|
fallbackLabel: string
|
||||||
|
) {
|
||||||
|
if (!data) {
|
||||||
|
return { totalMajor: '0.00', slices: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const membersById = new Map(data.members.map((member) => [member.memberId, member.displayName]))
|
||||||
|
const totals = new Map<string, { label: string; amountMinor: bigint }>()
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = entry.memberId ?? entry.actorDisplayName ?? entry.id
|
||||||
|
const label =
|
||||||
|
(entry.memberId ? membersById.get(entry.memberId) : null) ??
|
||||||
|
entry.actorDisplayName ??
|
||||||
|
fallbackLabel
|
||||||
|
const current = totals.get(key) ?? { label, amountMinor: 0n }
|
||||||
|
totals.set(key, {
|
||||||
|
label,
|
||||||
|
amountMinor: current.amountMinor + absoluteMinor(majorStringToMinor(entry.displayAmountMajor))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...totals.entries()]
|
||||||
|
.map(([key, value], index) => ({
|
||||||
|
key,
|
||||||
|
label: value.label,
|
||||||
|
amountMinor: value.amountMinor,
|
||||||
|
amountMajor: minorToMajorString(value.amountMinor),
|
||||||
|
color: chartPalette[index % chartPalette.length]!
|
||||||
|
}))
|
||||||
|
.filter((item) => item.amountMinor > 0n)
|
||||||
|
.sort((left, right) => (right.amountMinor > left.amountMinor ? 1 : -1))
|
||||||
|
|
||||||
|
const totalMinor = items.reduce((sum, item) => sum + item.amountMinor, 0n)
|
||||||
|
const circumference = 2 * Math.PI * 42
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMajor: minorToMajorString(totalMinor),
|
||||||
|
slices: items.map((item) => {
|
||||||
|
const ratio = totalMinor > 0n ? Number(item.amountMinor) / Number(totalMinor) : 0
|
||||||
|
const dash = ratio * circumference
|
||||||
|
const slice = {
|
||||||
|
...item,
|
||||||
|
percentage: Math.round(ratio * 100),
|
||||||
|
dasharray: `${dash} ${Math.max(circumference - dash, 0)}`,
|
||||||
|
dashoffset: `${-offset}`
|
||||||
|
}
|
||||||
|
offset += dash
|
||||||
|
return slice
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Provider ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function DashboardProvider(props: ParentProps) {
|
||||||
|
const { readySession } = useSession()
|
||||||
|
const { copy } = useI18n()
|
||||||
|
|
||||||
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
||||||
|
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
|
||||||
|
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
|
||||||
|
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
||||||
|
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null)
|
||||||
|
|
||||||
|
const effectiveIsAdmin = createMemo(() => {
|
||||||
|
const current = readySession()
|
||||||
|
if (!current?.member.isAdmin) return false
|
||||||
|
const preview = testingRolePreview()
|
||||||
|
if (!preview) return true
|
||||||
|
return preview === 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentMemberLine = createMemo(() => {
|
||||||
|
const current = readySession()
|
||||||
|
const data = dashboard()
|
||||||
|
if (!current || !data) return null
|
||||||
|
return data.members.find((m) => m.memberId === current.member.id) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const purchaseLedger = createMemo(() =>
|
||||||
|
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'purchase')
|
||||||
|
)
|
||||||
|
const utilityLedger = createMemo(() =>
|
||||||
|
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'utility')
|
||||||
|
)
|
||||||
|
const paymentLedger = createMemo(() =>
|
||||||
|
(dashboard()?.ledger ?? []).filter((e) => e.kind === 'payment')
|
||||||
|
)
|
||||||
|
|
||||||
|
const utilityTotalMajor = createMemo(() =>
|
||||||
|
minorToMajorString(
|
||||||
|
utilityLedger().reduce((sum, e) => sum + majorStringToMinor(e.displayAmountMajor), 0n)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const purchaseTotalMajor = createMemo(() =>
|
||||||
|
minorToMajorString(
|
||||||
|
purchaseLedger().reduce((sum, e) => sum + majorStringToMinor(e.displayAmountMajor), 0n)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const memberBalanceVisuals = createMemo(() => computeMemberBalanceVisuals(dashboard(), copy))
|
||||||
|
|
||||||
|
const purchaseInvestmentChart = createMemo(() =>
|
||||||
|
computePurchaseInvestmentChart(dashboard(), purchaseLedger(), copy().ledgerActorFallback)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadDashboardData(initData: string, isAdmin: boolean) {
|
||||||
|
// In demo mode, use demo data
|
||||||
|
if (!initData) {
|
||||||
|
applyDemoState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextDashboard = await fetchDashboardQuery(initData)
|
||||||
|
setDashboard(nextDashboard)
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Failed to load mini app dashboard', error)
|
||||||
|
}
|
||||||
|
setDashboard(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
try {
|
||||||
|
const [settings, cycle, pending] = await Promise.all([
|
||||||
|
fetchAdminSettingsQuery(initData),
|
||||||
|
fetchBillingCycleQuery(initData),
|
||||||
|
fetchPendingMembersQuery(initData)
|
||||||
|
])
|
||||||
|
setAdminSettings(settings)
|
||||||
|
setCycleState(cycle)
|
||||||
|
setPendingMembers(pending)
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('Failed to load admin data', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDemoState() {
|
||||||
|
setDashboard(demoDashboard)
|
||||||
|
setPendingMembers([...demoPendingMembers])
|
||||||
|
setAdminSettings(demoAdminSettings)
|
||||||
|
setCycleState(demoCycleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardContext.Provider
|
||||||
|
value={{
|
||||||
|
dashboard,
|
||||||
|
setDashboard,
|
||||||
|
adminSettings,
|
||||||
|
setAdminSettings,
|
||||||
|
cycleState,
|
||||||
|
setCycleState,
|
||||||
|
pendingMembers,
|
||||||
|
setPendingMembers,
|
||||||
|
effectiveIsAdmin,
|
||||||
|
currentMemberLine,
|
||||||
|
purchaseLedger,
|
||||||
|
utilityLedger,
|
||||||
|
paymentLedger,
|
||||||
|
utilityTotalMajor,
|
||||||
|
purchaseTotalMajor,
|
||||||
|
memberBalanceVisuals,
|
||||||
|
purchaseInvestmentChart,
|
||||||
|
testingRolePreview,
|
||||||
|
setTestingRolePreview,
|
||||||
|
loadDashboardData,
|
||||||
|
applyDemoState
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</DashboardContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDashboard(): DashboardContextValue {
|
||||||
|
const context = useContext(DashboardContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useDashboard must be used within a DashboardProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
38
apps/miniapp/src/contexts/i18n-context.tsx
Normal file
38
apps/miniapp/src/contexts/i18n-context.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { createContext, createSignal, useContext, type ParentProps } from 'solid-js'
|
||||||
|
|
||||||
|
import { dictionary, type Locale } from '../i18n'
|
||||||
|
import { getTelegramWebApp } from '../telegram-webapp'
|
||||||
|
|
||||||
|
type I18nContextValue = {
|
||||||
|
locale: () => Locale
|
||||||
|
setLocale: (locale: Locale) => void
|
||||||
|
copy: () => (typeof dictionary)['en']
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue>()
|
||||||
|
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code
|
||||||
|
const browserLocale = navigator.language.toLowerCase()
|
||||||
|
|
||||||
|
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function I18nProvider(props: ParentProps) {
|
||||||
|
const [locale, setLocale] = createSignal<Locale>(detectLocale())
|
||||||
|
const copy = () => dictionary[locale()]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext.Provider value={{ locale, setLocale, copy }}>
|
||||||
|
{props.children}
|
||||||
|
</I18nContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const context = useContext(I18nContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useI18n must be used within an I18nProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
353
apps/miniapp/src/contexts/session-context.tsx
Normal file
353
apps/miniapp/src/contexts/session-context.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { createContext, createSignal, onMount, useContext, type ParentProps } from 'solid-js'
|
||||||
|
|
||||||
|
import type { Locale } from '../i18n'
|
||||||
|
import {
|
||||||
|
joinMiniAppHousehold,
|
||||||
|
updateMiniAppLocalePreference,
|
||||||
|
updateMiniAppOwnDisplayName
|
||||||
|
} from '../miniapp-api'
|
||||||
|
import { fetchSessionQuery, invalidateHouseholdQueries } from '../app/miniapp-queries'
|
||||||
|
import { getTelegramWebApp } from '../telegram-webapp'
|
||||||
|
import { demoMember, demoTelegramUser } from '../demo/miniapp-demo'
|
||||||
|
import { useI18n } from './i18n-context'
|
||||||
|
|
||||||
|
/* ── Types ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type SessionState =
|
||||||
|
| { status: 'loading' }
|
||||||
|
| { status: 'blocked'; reason: 'telegram_only' | 'error' }
|
||||||
|
| {
|
||||||
|
status: 'onboarding'
|
||||||
|
mode: 'join_required' | 'pending' | 'open_from_group'
|
||||||
|
householdName?: string
|
||||||
|
telegramUser: {
|
||||||
|
firstName: string | null
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'ready'
|
||||||
|
mode: 'live' | 'demo'
|
||||||
|
member: {
|
||||||
|
id: string
|
||||||
|
householdName: string
|
||||||
|
displayName: string
|
||||||
|
status: 'active' | 'away' | 'left'
|
||||||
|
isAdmin: boolean
|
||||||
|
preferredLocale: Locale | null
|
||||||
|
householdDefaultLocale: Locale
|
||||||
|
}
|
||||||
|
telegramUser: {
|
||||||
|
firstName: string | null
|
||||||
|
username: string | null
|
||||||
|
languageCode: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionContextValue = {
|
||||||
|
session: () => SessionState
|
||||||
|
setSession: (updater: SessionState | ((prev: SessionState) => SessionState)) => void
|
||||||
|
readySession: () => Extract<SessionState, { status: 'ready' }> | null
|
||||||
|
onboardingSession: () => Extract<SessionState, { status: 'onboarding' }> | null
|
||||||
|
blockedSession: () => Extract<SessionState, { status: 'blocked' }> | null
|
||||||
|
webApp: ReturnType<typeof getTelegramWebApp>
|
||||||
|
initData: () => string | undefined
|
||||||
|
joining: () => boolean
|
||||||
|
displayNameDraft: () => string
|
||||||
|
setDisplayNameDraft: (value: string | ((prev: string) => string)) => void
|
||||||
|
savingOwnDisplayName: () => boolean
|
||||||
|
handleJoinHousehold: () => Promise<void>
|
||||||
|
handleSaveOwnDisplayName: () => Promise<void>
|
||||||
|
handleMemberLocaleChange: (nextLocale: Locale) => Promise<void>
|
||||||
|
handleHouseholdLocaleChange: (nextLocale: Locale) => Promise<void>
|
||||||
|
refreshHouseholdData: (includeAdmin?: boolean, forceRefresh?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue>()
|
||||||
|
|
||||||
|
/* ── Helpers ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function joinContext(): { joinToken?: string; botUsername?: string } {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const joinToken = params.get('join')?.trim()
|
||||||
|
const botUsername = params.get('bot')?.trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(joinToken ? { joinToken } : {}),
|
||||||
|
...(botUsername ? { botUsername } : {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinDeepLink(): string | null {
|
||||||
|
const context = joinContext()
|
||||||
|
if (!context.botUsername || !context.joinToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'demo',
|
||||||
|
member: demoMember,
|
||||||
|
telegramUser: demoTelegramUser
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Provider ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SessionProvider(
|
||||||
|
props: ParentProps<{
|
||||||
|
onReady?: (initData: string, isAdmin: boolean) => Promise<void>
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const { locale, setLocale } = useI18n()
|
||||||
|
const webApp = getTelegramWebApp()
|
||||||
|
|
||||||
|
const [session, setSession] = createSignal<SessionState>({ status: 'loading' })
|
||||||
|
const [joining, setJoining] = createSignal(false)
|
||||||
|
const [displayNameDraft, setDisplayNameDraft] = createSignal('')
|
||||||
|
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
|
||||||
|
|
||||||
|
const readySession = () => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'ready' ? current : null
|
||||||
|
}
|
||||||
|
const onboardingSession = () => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'onboarding' ? current : null
|
||||||
|
}
|
||||||
|
const blockedSession = () => {
|
||||||
|
const current = session()
|
||||||
|
return current.status === 'blocked' ? current : null
|
||||||
|
}
|
||||||
|
const initData = () => webApp?.initData?.trim() || undefined
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
webApp?.ready?.()
|
||||||
|
webApp?.expand?.()
|
||||||
|
|
||||||
|
const data = initData()
|
||||||
|
if (!data) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
setSession(demoSession)
|
||||||
|
setDisplayNameDraft(demoSession.member.displayName)
|
||||||
|
await props.onReady?.('', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSession({ status: 'blocked', reason: 'telegram_only' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchSessionQuery(data, joinContext().joinToken)
|
||||||
|
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||||
|
setLocale(
|
||||||
|
payload.onboarding?.householdDefaultLocale ??
|
||||||
|
((payload.telegramUser?.languageCode ?? 'en').startsWith('ru') ? 'ru' : 'en')
|
||||||
|
)
|
||||||
|
setSession({
|
||||||
|
status: 'onboarding',
|
||||||
|
mode: payload.onboarding?.status ?? 'open_from_group',
|
||||||
|
...(payload.onboarding?.householdName
|
||||||
|
? { householdName: payload.onboarding.householdName }
|
||||||
|
: {}),
|
||||||
|
telegramUser: payload.telegramUser ?? {
|
||||||
|
firstName: null,
|
||||||
|
username: null,
|
||||||
|
languageCode: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
|
setDisplayNameDraft(payload.member.displayName)
|
||||||
|
setSession({
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'live',
|
||||||
|
member: payload.member,
|
||||||
|
telegramUser: payload.telegramUser
|
||||||
|
})
|
||||||
|
await props.onReady?.(data, payload.member.isAdmin)
|
||||||
|
} catch {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
setSession(demoSession)
|
||||||
|
setDisplayNameDraft(demoSession.member.displayName)
|
||||||
|
await props.onReady?.('', true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSession({ status: 'blocked', reason: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleJoinHousehold() {
|
||||||
|
const data = initData()
|
||||||
|
const joinToken = joinContext().joinToken
|
||||||
|
if (!data || !joinToken || joining()) return
|
||||||
|
|
||||||
|
setJoining(true)
|
||||||
|
try {
|
||||||
|
const payload = await joinMiniAppHousehold(data, joinToken)
|
||||||
|
if (payload.authorized && payload.member && payload.telegramUser) {
|
||||||
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
|
setDisplayNameDraft(payload.member.displayName)
|
||||||
|
setSession({
|
||||||
|
status: 'ready',
|
||||||
|
mode: 'live',
|
||||||
|
member: payload.member,
|
||||||
|
telegramUser: payload.telegramUser
|
||||||
|
})
|
||||||
|
await props.onReady?.(data, payload.member.isAdmin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(
|
||||||
|
payload.onboarding?.householdDefaultLocale ??
|
||||||
|
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
|
||||||
|
)
|
||||||
|
setSession({
|
||||||
|
status: 'onboarding',
|
||||||
|
mode: payload.onboarding?.status ?? 'pending',
|
||||||
|
...(payload.onboarding?.householdName
|
||||||
|
? { householdName: payload.onboarding.householdName }
|
||||||
|
: {}),
|
||||||
|
telegramUser: payload.telegramUser ?? {
|
||||||
|
firstName: null,
|
||||||
|
username: null,
|
||||||
|
languageCode: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setSession({ status: 'blocked', reason: 'error' })
|
||||||
|
} finally {
|
||||||
|
setJoining(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveOwnDisplayName() {
|
||||||
|
const data = initData()
|
||||||
|
const current = readySession()
|
||||||
|
const nextName = displayNameDraft().trim()
|
||||||
|
if (!data || current?.mode !== 'live' || nextName.length === 0) return
|
||||||
|
|
||||||
|
setSavingOwnDisplayName(true)
|
||||||
|
try {
|
||||||
|
const updatedMember = await updateMiniAppOwnDisplayName(data, nextName)
|
||||||
|
setSession((prev) =>
|
||||||
|
prev.status === 'ready'
|
||||||
|
? { ...prev, member: { ...prev.member, displayName: updatedMember.displayName } }
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
setDisplayNameDraft(updatedMember.displayName)
|
||||||
|
} finally {
|
||||||
|
setSavingOwnDisplayName(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMemberLocaleChange(nextLocale: Locale) {
|
||||||
|
const data = initData()
|
||||||
|
const current = readySession()
|
||||||
|
setLocale(nextLocale)
|
||||||
|
|
||||||
|
if (!data || current?.mode !== 'live') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateMiniAppLocalePreference(data, nextLocale, 'member')
|
||||||
|
setSession((prev) =>
|
||||||
|
prev.status === 'ready'
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
member: {
|
||||||
|
...prev.member,
|
||||||
|
preferredLocale: updated.memberPreferredLocale,
|
||||||
|
householdDefaultLocale: updated.householdDefaultLocale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
setLocale(updated.effectiveLocale)
|
||||||
|
} catch {
|
||||||
|
// Locale was already set optimistically
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHouseholdLocaleChange(nextLocale: Locale) {
|
||||||
|
const data = initData()
|
||||||
|
const current = readySession()
|
||||||
|
if (!data || current?.mode !== 'live' || !current.member.isAdmin) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateMiniAppLocalePreference(data, nextLocale, 'household')
|
||||||
|
setSession((prev) =>
|
||||||
|
prev.status === 'ready'
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
member: { ...prev.member, householdDefaultLocale: updated.householdDefaultLocale }
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
)
|
||||||
|
if (!current.member.preferredLocale) {
|
||||||
|
setLocale(updated.effectiveLocale)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHouseholdData(includeAdmin = false, forceRefresh = false) {
|
||||||
|
const data = initData()
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
if (forceRefresh) {
|
||||||
|
await invalidateHouseholdQueries(data)
|
||||||
|
}
|
||||||
|
// Delegate actual data loading to dashboard context via onReady
|
||||||
|
const current = readySession()
|
||||||
|
if (current) {
|
||||||
|
await props.onReady?.(data, includeAdmin || current.member.isAdmin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void bootstrap()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionContext.Provider
|
||||||
|
value={{
|
||||||
|
session,
|
||||||
|
setSession,
|
||||||
|
readySession,
|
||||||
|
onboardingSession,
|
||||||
|
blockedSession,
|
||||||
|
webApp,
|
||||||
|
initData,
|
||||||
|
joining,
|
||||||
|
displayNameDraft,
|
||||||
|
setDisplayNameDraft,
|
||||||
|
savingOwnDisplayName,
|
||||||
|
handleJoinHousehold,
|
||||||
|
handleSaveOwnDisplayName,
|
||||||
|
handleMemberLocaleChange,
|
||||||
|
handleHouseholdLocaleChange,
|
||||||
|
refreshHouseholdData
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</SessionContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): SessionContextValue {
|
||||||
|
const context = useContext(SessionContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSession must be used within a SessionProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ export const dictionary = {
|
|||||||
overviewBody:
|
overviewBody:
|
||||||
'Use the sections below to review balances, ledger entries, and household access.',
|
'Use the sections below to review balances, ledger entries, and household access.',
|
||||||
totalDue: 'Total due',
|
totalDue: 'Total due',
|
||||||
|
totalDueLabel: 'Subtotal',
|
||||||
paidLabel: 'Paid',
|
paidLabel: 'Paid',
|
||||||
remainingLabel: 'Remaining',
|
remainingLabel: 'Remaining',
|
||||||
membersCount: 'Members',
|
membersCount: 'Members',
|
||||||
@@ -292,6 +293,8 @@ export const dictionary = {
|
|||||||
'Approve roommates here after they request access from the group join flow.',
|
'Approve roommates here after they request access from the group join flow.',
|
||||||
pendingMembersEmpty: 'No pending member requests right now.',
|
pendingMembersEmpty: 'No pending member requests right now.',
|
||||||
approveMemberAction: 'Approve',
|
approveMemberAction: 'Approve',
|
||||||
|
rejectMemberAction: 'Reject',
|
||||||
|
rejectingMember: 'Rejecting…',
|
||||||
approvingMember: 'Approving…',
|
approvingMember: 'Approving…',
|
||||||
pendingMemberHandle: '@{username}',
|
pendingMemberHandle: '@{username}',
|
||||||
balancesEmpty: 'Balances will appear here once the dashboard API lands.',
|
balancesEmpty: 'Balances will appear here once the dashboard API lands.',
|
||||||
@@ -348,6 +351,7 @@ export const dictionary = {
|
|||||||
overviewTitle: 'Текущий цикл',
|
overviewTitle: 'Текущий цикл',
|
||||||
overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к дому.',
|
overviewBody: 'Ниже можно посмотреть балансы, записи леджера и доступ к дому.',
|
||||||
totalDue: 'Итого к оплате',
|
totalDue: 'Итого к оплате',
|
||||||
|
totalDueLabel: 'Подытог',
|
||||||
paidLabel: 'Оплачено',
|
paidLabel: 'Оплачено',
|
||||||
remainingLabel: 'Осталось',
|
remainingLabel: 'Осталось',
|
||||||
membersCount: 'Участники',
|
membersCount: 'Участники',
|
||||||
@@ -591,6 +595,8 @@ export const dictionary = {
|
|||||||
'Подтверждай соседей здесь после того, как они отправят заявку через кнопку подключения.',
|
'Подтверждай соседей здесь после того, как они отправят заявку через кнопку подключения.',
|
||||||
pendingMembersEmpty: 'Сейчас нет ожидающих заявок.',
|
pendingMembersEmpty: 'Сейчас нет ожидающих заявок.',
|
||||||
approveMemberAction: 'Подтвердить',
|
approveMemberAction: 'Подтвердить',
|
||||||
|
rejectMemberAction: 'Отклонить',
|
||||||
|
rejectingMember: 'Отклонение…',
|
||||||
approvingMember: 'Подтверждаем…',
|
approvingMember: 'Подтверждаем…',
|
||||||
pendingMemberHandle: '@{username}',
|
pendingMemberHandle: '@{username}',
|
||||||
balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.',
|
balancesEmpty: 'Баланс появится здесь, когда подключим dashboard API.',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/solid-query'
|
|||||||
import { render } from 'solid-js/web'
|
import { render } from 'solid-js/web'
|
||||||
|
|
||||||
import { miniAppQueryClient } from './app/query-client'
|
import { miniAppQueryClient } from './app/query-client'
|
||||||
|
import './theme.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
|
|||||||
234
apps/miniapp/src/lib/ledger-helpers.ts
Normal file
234
apps/miniapp/src/lib/ledger-helpers.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/**
|
||||||
|
* Pure helper functions extracted from App.tsx for ledger, member, and draft operations.
|
||||||
|
* No side-effects, no framework imports — just data transformations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { majorStringToMinor, minorToMajorString } from './money'
|
||||||
|
import type {
|
||||||
|
MiniAppAdminCycleState,
|
||||||
|
MiniAppDashboard,
|
||||||
|
MiniAppMemberAbsencePolicy,
|
||||||
|
MiniAppMemberAbsencePolicyRecord,
|
||||||
|
MiniAppAdminSettingsPayload
|
||||||
|
} from '../miniapp-api'
|
||||||
|
|
||||||
|
/* ── Draft types ────────────────────────────────────── */
|
||||||
|
|
||||||
|
export type UtilityBillDraft = {
|
||||||
|
billName: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PurchaseDraft = {
|
||||||
|
description: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
splitMode: 'equal' | 'custom_amounts'
|
||||||
|
splitInputMode: 'equal' | 'exact' | 'percentage'
|
||||||
|
participants: {
|
||||||
|
memberId: string
|
||||||
|
included: boolean
|
||||||
|
shareAmountMajor: string
|
||||||
|
sharePercentage: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentDraft = {
|
||||||
|
memberId: string
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pure helpers ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export function absoluteMinor(value: bigint): bigint {
|
||||||
|
return value < 0n ? -value : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
|
||||||
|
return minorToMajorString(
|
||||||
|
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
|
||||||
|
const remainingMinor = majorStringToMinor(member.remainingMajor)
|
||||||
|
|
||||||
|
if (remainingMinor < 0n) {
|
||||||
|
return 'is-credit'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingMinor === 0n) {
|
||||||
|
return 'is-settled'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'is-due'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
||||||
|
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): string | null {
|
||||||
|
if (entry.currency === entry.displayCurrency && entry.amountMajor === entry.displayAmountMajor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${entry.amountMajor} ${entry.currency}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleUtilityBillDrafts(
|
||||||
|
bills: MiniAppAdminCycleState['utilityBills']
|
||||||
|
): Record<string, UtilityBillDraft> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
bills.map((bill) => [
|
||||||
|
bill.id,
|
||||||
|
{
|
||||||
|
billName: bill.billName,
|
||||||
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
||||||
|
currency: bill.currency
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purchaseDrafts(
|
||||||
|
entries: readonly MiniAppDashboard['ledger'][number][]
|
||||||
|
): Record<string, PurchaseDraft> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.kind === 'purchase')
|
||||||
|
.map((entry) => [
|
||||||
|
entry.id,
|
||||||
|
{
|
||||||
|
description: entry.title,
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency,
|
||||||
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
||||||
|
splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact',
|
||||||
|
participants:
|
||||||
|
entry.purchaseParticipants?.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included ?? true,
|
||||||
|
shareAmountMajor: participant.shareAmountMajor ?? '',
|
||||||
|
sharePercentage: ''
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PurchaseDraft {
|
||||||
|
return {
|
||||||
|
description: entry.title,
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency,
|
||||||
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
||||||
|
splitInputMode: (entry.purchaseSplitMode ?? 'equal') === 'equal' ? 'equal' : 'exact',
|
||||||
|
participants:
|
||||||
|
entry.purchaseParticipants?.map((participant) => ({
|
||||||
|
memberId: participant.memberId,
|
||||||
|
included: participant.included ?? true,
|
||||||
|
shareAmountMajor: participant.shareAmountMajor ?? '',
|
||||||
|
sharePercentage: ''
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentDrafts(
|
||||||
|
entries: readonly MiniAppDashboard['ledger'][number][]
|
||||||
|
): Record<string, PaymentDraft> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
entries
|
||||||
|
.filter((entry) => entry.kind === 'payment')
|
||||||
|
.map((entry) => [
|
||||||
|
entry.id,
|
||||||
|
{
|
||||||
|
memberId: entry.memberId ?? '',
|
||||||
|
kind: entry.paymentKind ?? 'rent',
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PaymentDraft {
|
||||||
|
return {
|
||||||
|
memberId: entry.memberId ?? '',
|
||||||
|
kind: entry.paymentKind ?? 'rent',
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCyclePeriod(): string {
|
||||||
|
return new Date().toISOString().slice(0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAbsencePolicyForStatus(
|
||||||
|
status: 'active' | 'away' | 'left'
|
||||||
|
): MiniAppMemberAbsencePolicy {
|
||||||
|
if (status === 'away') {
|
||||||
|
return 'away_rent_and_utilities'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'left') {
|
||||||
|
return 'inactive'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'resident'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedMemberAbsencePolicy(
|
||||||
|
memberId: string,
|
||||||
|
status: 'active' | 'away' | 'left',
|
||||||
|
settings?: MiniAppAdminSettingsPayload | null
|
||||||
|
): MiniAppMemberAbsencePolicyRecord {
|
||||||
|
const current = settings?.memberAbsencePolicies
|
||||||
|
.filter((policy) => policy.memberId === memberId)
|
||||||
|
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
|
||||||
|
.at(-1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
current ?? {
|
||||||
|
memberId,
|
||||||
|
effectiveFromPeriod: '',
|
||||||
|
policy: defaultAbsencePolicyForStatus(status)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the prefill amount for a payment based on member dues.
|
||||||
|
* Bug #5 fix: Prefill with the remaining amount for the selected payment kind.
|
||||||
|
*/
|
||||||
|
export function computePaymentPrefill(
|
||||||
|
member: MiniAppDashboard['members'][number] | null | undefined,
|
||||||
|
kind: 'rent' | 'utilities'
|
||||||
|
): string {
|
||||||
|
if (!member) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const rentMinor = majorStringToMinor(member.rentShareMajor)
|
||||||
|
const utilityMinor = majorStringToMinor(member.utilityShareMajor)
|
||||||
|
const remainingMinor = majorStringToMinor(member.remainingMajor)
|
||||||
|
|
||||||
|
if (remainingMinor <= 0n) {
|
||||||
|
return '0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate unpaid per kind (simplified: if total due matches,
|
||||||
|
// use share for that kind as an approximation)
|
||||||
|
const dueMinor = kind === 'rent' ? rentMinor : utilityMinor
|
||||||
|
if (dueMinor <= 0n) {
|
||||||
|
return '0.00'
|
||||||
|
}
|
||||||
|
|
||||||
|
// If remaining is less than due for this kind, use remaining
|
||||||
|
const prefillMinor = remainingMinor < dueMinor ? remainingMinor : dueMinor
|
||||||
|
return minorToMajorString(prefillMinor)
|
||||||
|
}
|
||||||
@@ -345,6 +345,32 @@ export async function approveMiniAppPendingMember(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function rejectMiniAppPendingMember(
|
||||||
|
initData: string,
|
||||||
|
pendingTelegramUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/reject-member`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
pendingTelegramUserId
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok: boolean
|
||||||
|
authorized?: boolean
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.authorized) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to reject member')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateMiniAppLocalePreference(
|
export async function updateMiniAppLocalePreference(
|
||||||
initData: string,
|
initData: string,
|
||||||
locale: 'en' | 'ru',
|
locale: 'en' | 'ru',
|
||||||
@@ -920,6 +946,39 @@ export async function deleteMiniAppUtilityBill(
|
|||||||
return payload.cycleState
|
return payload.cycleState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addMiniAppPurchase(
|
||||||
|
initData: string,
|
||||||
|
input: {
|
||||||
|
description: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
split?: {
|
||||||
|
mode: 'equal' | 'custom_amounts'
|
||||||
|
participants: readonly {
|
||||||
|
memberId: string
|
||||||
|
included?: boolean
|
||||||
|
shareAmountMajor?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/purchases/add`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
...input
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
|
||||||
|
if (!response.ok || !payload.authorized) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to add purchase')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateMiniAppPurchase(
|
export async function updateMiniAppPurchase(
|
||||||
initData: string,
|
initData: string,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
151
apps/miniapp/src/routes/balances.tsx
Normal file
151
apps/miniapp/src/routes/balances.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Show, For } from 'solid-js'
|
||||||
|
import { BarChart3 } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { useI18n } from '../contexts/i18n-context'
|
||||||
|
import { useDashboard } from '../contexts/dashboard-context'
|
||||||
|
import { Card } from '../components/ui/card'
|
||||||
|
import { memberRemainingClass } from '../lib/ledger-helpers'
|
||||||
|
|
||||||
|
export default function BalancesRoute() {
|
||||||
|
const { copy } = useI18n()
|
||||||
|
const { dashboard, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="route route--balances">
|
||||||
|
<Show
|
||||||
|
when={dashboard()}
|
||||||
|
fallback={
|
||||||
|
<Card>
|
||||||
|
<p class="empty-state">{copy().balancesEmpty}</p>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<>
|
||||||
|
{/* ── Household balances ─────────────────── */}
|
||||||
|
<Card>
|
||||||
|
<div class="section-header">
|
||||||
|
<strong>{copy().householdBalancesTitle}</strong>
|
||||||
|
<p>{copy().householdBalancesBody}</p>
|
||||||
|
</div>
|
||||||
|
<div class="member-balance-list">
|
||||||
|
<For each={data().members}>
|
||||||
|
{(member) => (
|
||||||
|
<div class={`member-balance-row ${memberRemainingClass(member)}`}>
|
||||||
|
<span class="member-balance-row__name">{member.displayName}</span>
|
||||||
|
<div class="member-balance-row__amounts">
|
||||||
|
<span class="member-balance-row__due">
|
||||||
|
{member.netDueMajor} {data().currency}
|
||||||
|
</span>
|
||||||
|
<span class="member-balance-row__remaining">
|
||||||
|
{member.remainingMajor} {data().currency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Balance breakdown bars ──────────────── */}
|
||||||
|
<Card>
|
||||||
|
<div class="section-header">
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
<strong>{copy().financeVisualsTitle}</strong>
|
||||||
|
<p>{copy().financeVisualsBody}</p>
|
||||||
|
</div>
|
||||||
|
<div class="balance-visuals">
|
||||||
|
<For each={memberBalanceVisuals()}>
|
||||||
|
{(item) => (
|
||||||
|
<div class="balance-bar-row">
|
||||||
|
<span class="balance-bar-row__name">{item.member.displayName}</span>
|
||||||
|
<div
|
||||||
|
class="balance-bar-row__track"
|
||||||
|
style={{ width: `${Math.max(item.barWidthPercent, 8)}%` }}
|
||||||
|
>
|
||||||
|
<For each={item.segments}>
|
||||||
|
{(segment) => (
|
||||||
|
<div
|
||||||
|
class={`balance-bar-row__segment balance-bar-row__segment--${segment.key}`}
|
||||||
|
style={{ width: `${segment.widthPercent}%` }}
|
||||||
|
title={`${segment.label}: ${segment.amountMajor} ${data().currency}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
<span class={`balance-bar-row__label ${memberRemainingClass(item.member)}`}>
|
||||||
|
{item.member.remainingMajor} {data().currency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<div class="balance-bar-legend">
|
||||||
|
<span class="balance-bar-legend__item balance-bar-legend__item--rent">
|
||||||
|
{copy().shareRent}
|
||||||
|
</span>
|
||||||
|
<span class="balance-bar-legend__item balance-bar-legend__item--utilities">
|
||||||
|
{copy().shareUtilities}
|
||||||
|
</span>
|
||||||
|
<span class="balance-bar-legend__item balance-bar-legend__item--purchase">
|
||||||
|
{copy().shareOffset}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Purchase investment donut ───────────── */}
|
||||||
|
<Card>
|
||||||
|
<div class="section-header">
|
||||||
|
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
||||||
|
<p>{copy().purchaseInvestmentsBody}</p>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={purchaseInvestmentChart().slices.length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().purchaseInvestmentsEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="donut-chart">
|
||||||
|
<svg viewBox="0 0 100 100" class="donut-chart__svg">
|
||||||
|
<For each={purchaseInvestmentChart().slices}>
|
||||||
|
{(slice) => (
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="42"
|
||||||
|
fill="none"
|
||||||
|
stroke={slice.color}
|
||||||
|
stroke-width="12"
|
||||||
|
stroke-dasharray={slice.dasharray}
|
||||||
|
stroke-dashoffset={slice.dashoffset}
|
||||||
|
class="donut-chart__slice"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<text x="50" y="48" text-anchor="middle" class="donut-chart__total">
|
||||||
|
{purchaseInvestmentChart().totalMajor}
|
||||||
|
</text>
|
||||||
|
<text x="50" y="58" text-anchor="middle" class="donut-chart__label">
|
||||||
|
{data().currency}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div class="donut-chart__legend">
|
||||||
|
<For each={purchaseInvestmentChart().slices}>
|
||||||
|
{(slice) => (
|
||||||
|
<div class="donut-chart__legend-item">
|
||||||
|
<span class="donut-chart__color" style={{ background: slice.color }} />
|
||||||
|
<span>{slice.label}</span>
|
||||||
|
<strong>
|
||||||
|
{slice.amountMajor} ({slice.percentage}%)
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
apps/miniapp/src/routes/home.tsx
Normal file
158
apps/miniapp/src/routes/home.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Show, For } from 'solid-js'
|
||||||
|
import { Clock } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { useSession } from '../contexts/session-context'
|
||||||
|
import { useI18n } from '../contexts/i18n-context'
|
||||||
|
import { useDashboard } from '../contexts/dashboard-context'
|
||||||
|
import { Card } from '../components/ui/card'
|
||||||
|
import { Badge } from '../components/ui/badge'
|
||||||
|
import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers'
|
||||||
|
import { majorStringToMinor, minorToMajorString } from '../lib/money'
|
||||||
|
|
||||||
|
export default function HomeRoute() {
|
||||||
|
const { readySession } = useSession()
|
||||||
|
const { copy } = useI18n()
|
||||||
|
const { dashboard, currentMemberLine } = useDashboard()
|
||||||
|
|
||||||
|
function dueStatusBadge() {
|
||||||
|
const data = dashboard()
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
const remaining = majorStringToMinor(data.totalRemainingMajor)
|
||||||
|
if (remaining <= 0n) return { label: copy().homeSettledTitle, variant: 'accent' as const }
|
||||||
|
return { label: copy().homeDueTitle, variant: 'danger' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="route route--home">
|
||||||
|
{/* ── Welcome hero ────────────────────────────── */}
|
||||||
|
<div class="home-hero">
|
||||||
|
<p class="home-hero__greeting">{copy().welcome},</p>
|
||||||
|
<h2 class="home-hero__name">{readySession()?.member.displayName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Dashboard stats ─────────────────────────── */}
|
||||||
|
<Show
|
||||||
|
when={dashboard()}
|
||||||
|
fallback={
|
||||||
|
<Card>
|
||||||
|
<p class="empty-state">{copy().emptyDashboard}</p>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<>
|
||||||
|
{/* Your balance card */}
|
||||||
|
<Show when={currentMemberLine()}>
|
||||||
|
{(member) => {
|
||||||
|
const subtotalMinor =
|
||||||
|
majorStringToMinor(member().rentShareMajor) +
|
||||||
|
majorStringToMinor(member().utilityShareMajor)
|
||||||
|
const subtotalMajor = minorToMajorString(subtotalMinor)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card accent>
|
||||||
|
<div class="balance-card">
|
||||||
|
<div class="balance-card__header">
|
||||||
|
<span class="balance-card__label">{copy().yourBalanceTitle}</span>
|
||||||
|
<Show when={dueStatusBadge()}>
|
||||||
|
{(badge) => <Badge variant={badge().variant}>{badge().label}</Badge>}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card__amounts">
|
||||||
|
<div class="balance-card__row">
|
||||||
|
<span>{copy().shareRent}</span>
|
||||||
|
<strong>
|
||||||
|
{member().rentShareMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card__row">
|
||||||
|
<span>{copy().shareUtilities}</span>
|
||||||
|
<strong>
|
||||||
|
{member().utilityShareMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card__row balance-card__row--subtotal">
|
||||||
|
<span>{copy().totalDueLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{subtotalMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="balance-card__row">
|
||||||
|
<span>{copy().balanceAdjustmentLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{member().purchaseOffsetMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class={`balance-card__row balance-card__remaining ${memberRemainingClass(member())}`}
|
||||||
|
>
|
||||||
|
<span>{copy().remainingLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{member().remainingMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Rent FX card */}
|
||||||
|
<Show when={data().rentSourceCurrency !== data().currency}>
|
||||||
|
<Card muted>
|
||||||
|
<div class="fx-card">
|
||||||
|
<strong class="fx-card__title">{copy().rentFxTitle}</strong>
|
||||||
|
<div class="fx-card__row">
|
||||||
|
<span>{copy().sourceAmountLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{data().rentSourceAmountMajor} {data().rentSourceCurrency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="fx-card__row">
|
||||||
|
<span>{copy().settlementAmountLabel}</span>
|
||||||
|
<strong>
|
||||||
|
{data().rentDisplayAmountMajor} {data().currency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<Show when={data().rentFxEffectiveDate}>
|
||||||
|
<div class="fx-card__row fx-card__row--muted">
|
||||||
|
<span>{copy().fxEffectiveDateLabel}</span>
|
||||||
|
<span>{data().rentFxEffectiveDate}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Latest activity */}
|
||||||
|
<Card>
|
||||||
|
<div class="activity-card">
|
||||||
|
<div class="activity-card__header">
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>{copy().latestActivityTitle}</span>
|
||||||
|
</div>
|
||||||
|
<Show
|
||||||
|
when={data().ledger.length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().latestActivityEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="activity-card__list">
|
||||||
|
<For each={data().ledger.slice(0, 5)}>
|
||||||
|
{(entry) => (
|
||||||
|
<div class="activity-card__item">
|
||||||
|
<span class="activity-card__title">{entry.title}</span>
|
||||||
|
<span class="activity-card__amount">{ledgerPrimaryAmount(entry)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
971
apps/miniapp/src/routes/ledger.tsx
Normal file
971
apps/miniapp/src/routes/ledger.tsx
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
import { Show, For, createSignal, createMemo } from 'solid-js'
|
||||||
|
import { Plus } from 'lucide-solid'
|
||||||
|
|
||||||
|
import { useSession } from '../contexts/session-context'
|
||||||
|
import { useI18n } from '../contexts/i18n-context'
|
||||||
|
import { useDashboard } from '../contexts/dashboard-context'
|
||||||
|
import { Card } from '../components/ui/card'
|
||||||
|
import { Button } from '../components/ui/button'
|
||||||
|
import { Modal } from '../components/ui/dialog'
|
||||||
|
import { Input } from '../components/ui/input'
|
||||||
|
import { Select } from '../components/ui/select'
|
||||||
|
import { Field } from '../components/ui/field'
|
||||||
|
import { Collapsible } from '../components/ui/collapsible'
|
||||||
|
import { Toggle } from '../components/ui/toggle'
|
||||||
|
import {
|
||||||
|
ledgerPrimaryAmount,
|
||||||
|
ledgerSecondaryAmount,
|
||||||
|
purchaseDraftForEntry,
|
||||||
|
paymentDraftForEntry,
|
||||||
|
computePaymentPrefill,
|
||||||
|
type PurchaseDraft,
|
||||||
|
type PaymentDraft
|
||||||
|
} from '../lib/ledger-helpers'
|
||||||
|
import {
|
||||||
|
addMiniAppPurchase,
|
||||||
|
updateMiniAppPurchase,
|
||||||
|
deleteMiniAppPurchase,
|
||||||
|
addMiniAppPayment,
|
||||||
|
updateMiniAppPayment,
|
||||||
|
deleteMiniAppPayment,
|
||||||
|
addMiniAppUtilityBill,
|
||||||
|
updateMiniAppUtilityBill,
|
||||||
|
deleteMiniAppUtilityBill,
|
||||||
|
type MiniAppDashboard
|
||||||
|
} from '../miniapp-api'
|
||||||
|
|
||||||
|
export default function LedgerRoute() {
|
||||||
|
const { initData, refreshHouseholdData } = useSession()
|
||||||
|
const { copy } = useI18n()
|
||||||
|
const { dashboard, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
|
||||||
|
useDashboard()
|
||||||
|
|
||||||
|
// ── Purchase editor ──────────────────────────────
|
||||||
|
const [editingPurchase, setEditingPurchase] = createSignal<
|
||||||
|
MiniAppDashboard['ledger'][number] | null
|
||||||
|
>(null)
|
||||||
|
const [purchaseDraft, setPurchaseDraft] = createSignal<PurchaseDraft | null>(null)
|
||||||
|
const [savingPurchase, setSavingPurchase] = createSignal(false)
|
||||||
|
const [deletingPurchase, setDeletingPurchase] = createSignal(false)
|
||||||
|
|
||||||
|
// ── New purchase form (Bug #4 fix) ───────────────
|
||||||
|
const [addPurchaseOpen, setAddPurchaseOpen] = createSignal(false)
|
||||||
|
const [newPurchase, setNewPurchase] = createSignal<PurchaseDraft>({
|
||||||
|
description: '',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
|
||||||
|
splitMode: 'equal',
|
||||||
|
splitInputMode: 'equal',
|
||||||
|
participants: []
|
||||||
|
})
|
||||||
|
const [addingPurchase, setAddingPurchase] = createSignal(false)
|
||||||
|
|
||||||
|
// ── Utility bill editor ──────────────────────────
|
||||||
|
const [editingUtility, setEditingUtility] = createSignal<
|
||||||
|
MiniAppDashboard['ledger'][number] | null
|
||||||
|
>(null)
|
||||||
|
const [utilityDraft, setUtilityDraft] = createSignal<{
|
||||||
|
billName: string
|
||||||
|
amountMajor: string
|
||||||
|
currency: 'USD' | 'GEL'
|
||||||
|
} | null>(null)
|
||||||
|
const [savingUtility, setSavingUtility] = createSignal(false)
|
||||||
|
const [deletingUtility, setDeletingUtility] = createSignal(false)
|
||||||
|
|
||||||
|
// ── New utility bill form ────────────────────────
|
||||||
|
const [addUtilityOpen, setAddUtilityOpen] = createSignal(false)
|
||||||
|
const [newUtility, setNewUtility] = createSignal({
|
||||||
|
billName: '',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
||||||
|
})
|
||||||
|
const [addingUtility, setAddingUtility] = createSignal(false)
|
||||||
|
|
||||||
|
// ── Payment editor ───────────────────────────────
|
||||||
|
const [editingPayment, setEditingPayment] = createSignal<
|
||||||
|
MiniAppDashboard['ledger'][number] | null
|
||||||
|
>(null)
|
||||||
|
const [paymentDraftState, setPaymentDraft] = createSignal<PaymentDraft | null>(null)
|
||||||
|
const [savingPayment, setSavingPayment] = createSignal(false)
|
||||||
|
const [deletingPayment, setDeletingPayment] = createSignal(false)
|
||||||
|
|
||||||
|
// ── New payment form ─────────────────────────────
|
||||||
|
const [addPaymentOpen, setAddPaymentOpen] = createSignal(false)
|
||||||
|
const [newPayment, setNewPayment] = createSignal<PaymentDraft>({
|
||||||
|
memberId: '',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
||||||
|
})
|
||||||
|
const [addingPayment, setAddingPayment] = createSignal(false)
|
||||||
|
|
||||||
|
function openPurchaseEditor(entry: MiniAppDashboard['ledger'][number]) {
|
||||||
|
setEditingPurchase(entry)
|
||||||
|
setPurchaseDraft(purchaseDraftForEntry(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePurchaseEditor() {
|
||||||
|
setEditingPurchase(null)
|
||||||
|
setPurchaseDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPaymentEditor(entry: MiniAppDashboard['ledger'][number]) {
|
||||||
|
setEditingPayment(entry)
|
||||||
|
setPaymentDraft(paymentDraftForEntry(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePaymentEditor() {
|
||||||
|
setEditingPayment(null)
|
||||||
|
setPaymentDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUtilityEditor(entry: MiniAppDashboard['ledger'][number]) {
|
||||||
|
setEditingUtility(entry)
|
||||||
|
setUtilityDraft({
|
||||||
|
billName: entry.title,
|
||||||
|
amountMajor: entry.amountMajor,
|
||||||
|
currency: entry.currency as 'USD' | 'GEL'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUtilityEditor() {
|
||||||
|
setEditingUtility(null)
|
||||||
|
setUtilityDraft(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddUtility() {
|
||||||
|
const data = initData()
|
||||||
|
const draft = newUtility()
|
||||||
|
if (!data || !draft.billName.trim() || !draft.amountMajor.trim()) return
|
||||||
|
|
||||||
|
setAddingUtility(true)
|
||||||
|
try {
|
||||||
|
await addMiniAppUtilityBill(data, draft)
|
||||||
|
setAddUtilityOpen(false)
|
||||||
|
setNewUtility({
|
||||||
|
billName: '',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
||||||
|
})
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setAddingUtility(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveUtility() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingUtility()
|
||||||
|
const draft = utilityDraft()
|
||||||
|
if (!data || !entry || !draft) return
|
||||||
|
|
||||||
|
setSavingUtility(true)
|
||||||
|
try {
|
||||||
|
await updateMiniAppUtilityBill(data, {
|
||||||
|
billId: entry.id,
|
||||||
|
...draft
|
||||||
|
})
|
||||||
|
closeUtilityEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setSavingUtility(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteUtility() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingUtility()
|
||||||
|
if (!data || !entry) return
|
||||||
|
|
||||||
|
setDeletingUtility(true)
|
||||||
|
try {
|
||||||
|
await deleteMiniAppUtilityBill(data, entry.id)
|
||||||
|
closeUtilityEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setDeletingUtility(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePurchase() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingPurchase()
|
||||||
|
const draft = purchaseDraft()
|
||||||
|
if (!data || !entry || !draft) return
|
||||||
|
|
||||||
|
setSavingPurchase(true)
|
||||||
|
try {
|
||||||
|
await updateMiniAppPurchase(data, {
|
||||||
|
purchaseId: entry.id,
|
||||||
|
description: draft.description,
|
||||||
|
amountMajor: draft.amountMajor,
|
||||||
|
currency: draft.currency,
|
||||||
|
split: {
|
||||||
|
mode: draft.splitMode,
|
||||||
|
participants: draft.participants.map((p) => ({
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: p.included,
|
||||||
|
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
|
||||||
|
? { shareAmountMajor: p.shareAmountMajor }
|
||||||
|
: {})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
closePurchaseEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setSavingPurchase(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeletePurchase() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingPurchase()
|
||||||
|
if (!data || !entry) return
|
||||||
|
|
||||||
|
setDeletingPurchase(true)
|
||||||
|
try {
|
||||||
|
await deleteMiniAppPurchase(data, entry.id)
|
||||||
|
closePurchaseEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setDeletingPurchase(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPurchase() {
|
||||||
|
const data = initData()
|
||||||
|
const draft = newPurchase()
|
||||||
|
if (!data || !draft.description.trim() || !draft.amountMajor.trim()) return
|
||||||
|
|
||||||
|
setAddingPurchase(true)
|
||||||
|
try {
|
||||||
|
await addMiniAppPurchase(data, {
|
||||||
|
description: draft.description,
|
||||||
|
amountMajor: draft.amountMajor,
|
||||||
|
currency: draft.currency,
|
||||||
|
...(draft.participants.length > 0
|
||||||
|
? {
|
||||||
|
split: {
|
||||||
|
mode: draft.splitMode,
|
||||||
|
participants: draft.participants.map((p) => ({
|
||||||
|
memberId: p.memberId,
|
||||||
|
included: p.included,
|
||||||
|
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
|
||||||
|
? { shareAmountMajor: p.shareAmountMajor }
|
||||||
|
: {})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {})
|
||||||
|
})
|
||||||
|
setAddPurchaseOpen(false)
|
||||||
|
setNewPurchase({
|
||||||
|
description: '',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
|
||||||
|
splitMode: 'equal',
|
||||||
|
splitInputMode: 'equal',
|
||||||
|
participants: []
|
||||||
|
})
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setAddingPurchase(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSavePayment() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingPayment()
|
||||||
|
const draft = paymentDraftState()
|
||||||
|
if (!data || !entry || !draft) return
|
||||||
|
|
||||||
|
setSavingPayment(true)
|
||||||
|
try {
|
||||||
|
await updateMiniAppPayment(data, {
|
||||||
|
paymentId: entry.id,
|
||||||
|
memberId: draft.memberId,
|
||||||
|
kind: draft.kind,
|
||||||
|
amountMajor: draft.amountMajor,
|
||||||
|
currency: draft.currency
|
||||||
|
})
|
||||||
|
closePaymentEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setSavingPayment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeletePayment() {
|
||||||
|
const data = initData()
|
||||||
|
const entry = editingPayment()
|
||||||
|
if (!data || !entry) return
|
||||||
|
|
||||||
|
setDeletingPayment(true)
|
||||||
|
try {
|
||||||
|
await deleteMiniAppPayment(data, entry.id)
|
||||||
|
closePaymentEditor()
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setDeletingPayment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddPayment() {
|
||||||
|
const data = initData()
|
||||||
|
const draft = newPayment()
|
||||||
|
if (!data || !draft.memberId || !draft.amountMajor.trim()) return
|
||||||
|
|
||||||
|
setAddingPayment(true)
|
||||||
|
try {
|
||||||
|
await addMiniAppPayment(data, {
|
||||||
|
memberId: draft.memberId,
|
||||||
|
kind: draft.kind,
|
||||||
|
amountMajor: draft.amountMajor,
|
||||||
|
currency: draft.currency
|
||||||
|
})
|
||||||
|
setAddPaymentOpen(false)
|
||||||
|
setNewPayment({
|
||||||
|
memberId: '',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
|
||||||
|
})
|
||||||
|
await refreshHouseholdData(true, true)
|
||||||
|
} finally {
|
||||||
|
setAddingPayment(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currencyOptions = () => [
|
||||||
|
{ value: 'GEL', label: 'GEL' },
|
||||||
|
{ value: 'USD', label: 'USD' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const kindOptions = () => [
|
||||||
|
{ value: 'rent', label: copy().shareRent },
|
||||||
|
{ value: 'utilities', label: copy().shareUtilities }
|
||||||
|
]
|
||||||
|
|
||||||
|
const memberOptions = createMemo(() =>
|
||||||
|
(dashboard()?.members ?? []).map((m) => ({ value: m.memberId, label: m.displayName }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const splitModeOptions = () => [
|
||||||
|
{ value: 'equal', label: copy().purchaseSplitEqual },
|
||||||
|
{ value: 'exact', label: 'Exact amounts' },
|
||||||
|
{ value: 'percentage', label: 'Percentages' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderParticipantSplitInputs(
|
||||||
|
draft: PurchaseDraft,
|
||||||
|
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="split-configuration"
|
||||||
|
style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'margin-top': '8px' }}
|
||||||
|
>
|
||||||
|
<For each={draft.participants}>
|
||||||
|
{(participant, idx) => {
|
||||||
|
const member = dashboard()?.members.find((m) => m.memberId === participant.memberId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="split-participant"
|
||||||
|
style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}
|
||||||
|
>
|
||||||
|
<Toggle
|
||||||
|
checked={participant.included}
|
||||||
|
onChange={(checked) => {
|
||||||
|
updateDraft((d) => {
|
||||||
|
const newParticipants = [...d.participants]
|
||||||
|
newParticipants[idx()] = { ...participant, included: checked }
|
||||||
|
return { ...d, participants: newParticipants }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1 }}>{member?.displayName ?? 'Unknown'}</span>
|
||||||
|
<Show when={participant.included && draft.splitInputMode === 'exact'}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
style={{ width: '100px' }}
|
||||||
|
placeholder="0.00"
|
||||||
|
value={participant.shareAmountMajor}
|
||||||
|
onInput={(e) => {
|
||||||
|
updateDraft((d) => {
|
||||||
|
const newParticipants = [...d.participants]
|
||||||
|
newParticipants[idx()] = {
|
||||||
|
...participant,
|
||||||
|
shareAmountMajor: e.currentTarget.value
|
||||||
|
}
|
||||||
|
return { ...d, participants: newParticipants }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={participant.included && draft.splitInputMode === 'percentage'}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
placeholder="%"
|
||||||
|
value={participant.sharePercentage}
|
||||||
|
onInput={(e) => {
|
||||||
|
updateDraft((d) => {
|
||||||
|
const newParticipants = [...d.participants]
|
||||||
|
newParticipants[idx()] = {
|
||||||
|
...participant,
|
||||||
|
sharePercentage: e.currentTarget.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate exact amount based on percentage
|
||||||
|
const percentage = parseFloat(e.currentTarget.value) || 0
|
||||||
|
const totalMajor = parseFloat(d.amountMajor) || 0
|
||||||
|
const exactAmount = (totalMajor * percentage) / 100
|
||||||
|
const nextParticipant = newParticipants[idx()]
|
||||||
|
if (nextParticipant) {
|
||||||
|
nextParticipant.shareAmountMajor =
|
||||||
|
exactAmount > 0 ? exactAmount.toFixed(2) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...d, participants: newParticipants }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="route route--ledger">
|
||||||
|
<Show
|
||||||
|
when={dashboard()}
|
||||||
|
fallback={
|
||||||
|
<Card>
|
||||||
|
<p class="empty-state">{copy().ledgerEmpty}</p>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(_data) => (
|
||||||
|
<>
|
||||||
|
{/* ── Purchases ──────────────────────────── */}
|
||||||
|
<Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen>
|
||||||
|
<Show when={effectiveIsAdmin()}>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setAddPurchaseOpen(true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{copy().purchaseSaveAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={purchaseLedger().length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="ledger-list">
|
||||||
|
<For each={purchaseLedger()}>
|
||||||
|
{(entry) => (
|
||||||
|
<button
|
||||||
|
class="ledger-entry"
|
||||||
|
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
|
||||||
|
disabled={!effectiveIsAdmin()}
|
||||||
|
>
|
||||||
|
<div class="ledger-entry__main">
|
||||||
|
<span class="ledger-entry__title">{entry.title}</span>
|
||||||
|
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-entry__amounts">
|
||||||
|
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||||
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
|
{(secondary) => (
|
||||||
|
<span class="ledger-entry__secondary">{secondary()}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ── Utility bills ──────────────────────── */}
|
||||||
|
<Collapsible title={copy().utilityLedgerTitle}>
|
||||||
|
<Show when={effectiveIsAdmin()}>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{copy().addUtilityBillAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={utilityLedger().length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="ledger-list">
|
||||||
|
<For each={utilityLedger()}>
|
||||||
|
{(entry) => (
|
||||||
|
<button
|
||||||
|
class="ledger-entry"
|
||||||
|
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
|
||||||
|
disabled={!effectiveIsAdmin()}
|
||||||
|
>
|
||||||
|
<div class="ledger-entry__main">
|
||||||
|
<span class="ledger-entry__title">{entry.title}</span>
|
||||||
|
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-entry__amounts">
|
||||||
|
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ── Payments ───────────────────────────── */}
|
||||||
|
<Collapsible
|
||||||
|
title={copy().paymentsTitle}
|
||||||
|
{...(effectiveIsAdmin() && copy().paymentsAdminBody
|
||||||
|
? { body: copy().paymentsAdminBody }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<Show when={effectiveIsAdmin()}>
|
||||||
|
<div class="ledger-actions">
|
||||||
|
<Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}>
|
||||||
|
<Plus size={14} />
|
||||||
|
{copy().paymentsAddAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={paymentLedger().length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="ledger-list">
|
||||||
|
<For each={paymentLedger()}>
|
||||||
|
{(entry) => (
|
||||||
|
<button
|
||||||
|
class="ledger-entry"
|
||||||
|
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
|
||||||
|
disabled={!effectiveIsAdmin()}
|
||||||
|
>
|
||||||
|
<div class="ledger-entry__main">
|
||||||
|
<span class="ledger-entry__title">
|
||||||
|
{entry.paymentKind === 'rent'
|
||||||
|
? copy().paymentLedgerRent
|
||||||
|
: copy().paymentLedgerUtilities}
|
||||||
|
</span>
|
||||||
|
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-entry__amounts">
|
||||||
|
<strong>{ledgerPrimaryAmount(entry)}</strong>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* ──────── Add Purchase Modal (Bug #4 fix) ──── */}
|
||||||
|
<Modal
|
||||||
|
open={addPurchaseOpen()}
|
||||||
|
title={copy().purchaseSaveAction}
|
||||||
|
description={copy().purchaseEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setAddPurchaseOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setAddPurchaseOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={addingPurchase()}
|
||||||
|
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
|
||||||
|
onClick={() => void handleAddPurchase()}
|
||||||
|
>
|
||||||
|
{copy().purchaseSaveAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().purchaseReviewTitle}>
|
||||||
|
<Input
|
||||||
|
value={newPurchase().description}
|
||||||
|
onInput={(e) => setNewPurchase((p) => ({ ...p, description: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newPurchase().amountMajor}
|
||||||
|
onInput={(e) => setNewPurchase((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().currencyLabel}>
|
||||||
|
<Select
|
||||||
|
value={newPurchase().currency}
|
||||||
|
ariaLabel={copy().currencyLabel}
|
||||||
|
options={currencyOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewPurchase((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div style={{ 'grid-column': '1 / -1' }}>
|
||||||
|
<Field label="Split By">
|
||||||
|
<Select
|
||||||
|
value={newPurchase().splitInputMode}
|
||||||
|
ariaLabel="Split By"
|
||||||
|
options={splitModeOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewPurchase((p) => {
|
||||||
|
const splitInputMode = value as 'equal' | 'exact' | 'percentage'
|
||||||
|
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
|
||||||
|
return { ...p, splitInputMode, splitMode }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{renderParticipantSplitInputs(newPurchase(), (updater) =>
|
||||||
|
setNewPurchase((prev) => updater(prev))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ──────── Edit Purchase Modal ───────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!editingPurchase()}
|
||||||
|
title={copy().editEntryAction}
|
||||||
|
description={copy().purchaseEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={closePurchaseEditor}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deletingPurchase()}
|
||||||
|
onClick={() => void handleDeletePurchase()}
|
||||||
|
>
|
||||||
|
{deletingPurchase() ? copy().deletingPurchase : copy().purchaseDeleteAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={savingPurchase()}
|
||||||
|
onClick={() => void handleSavePurchase()}
|
||||||
|
>
|
||||||
|
{savingPurchase() ? copy().savingPurchase : copy().purchaseSaveAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={purchaseDraft()}>
|
||||||
|
{(draft) => (
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().purchaseReviewTitle}>
|
||||||
|
<Input
|
||||||
|
value={draft().description}
|
||||||
|
onInput={(e) =>
|
||||||
|
setPurchaseDraft((d) => (d ? { ...d, description: e.currentTarget.value } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft().amountMajor}
|
||||||
|
onInput={(e) =>
|
||||||
|
setPurchaseDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().currencyLabel}>
|
||||||
|
<Select
|
||||||
|
value={draft().currency}
|
||||||
|
ariaLabel={copy().currencyLabel}
|
||||||
|
options={currencyOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setPurchaseDraft((d) => (d ? { ...d, currency: value as 'USD' | 'GEL' } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div style={{ 'grid-column': '1 / -1' }}>
|
||||||
|
<Field label="Split By">
|
||||||
|
<Select
|
||||||
|
value={draft().splitInputMode}
|
||||||
|
ariaLabel="Split By"
|
||||||
|
options={splitModeOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setPurchaseDraft((d) => {
|
||||||
|
if (!d) return d
|
||||||
|
const splitInputMode = value as 'equal' | 'exact' | 'percentage'
|
||||||
|
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
|
||||||
|
return { ...d, splitInputMode, splitMode }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{renderParticipantSplitInputs(draft(), (updater) =>
|
||||||
|
setPurchaseDraft((prev) => (prev ? updater(prev) : prev))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ──────── Add Payment Modal ─────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={addPaymentOpen()}
|
||||||
|
title={copy().paymentsAddAction}
|
||||||
|
description={copy().paymentCreateBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setAddPaymentOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setAddPaymentOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={addingPayment()}
|
||||||
|
disabled={!newPayment().memberId || !newPayment().amountMajor.trim()}
|
||||||
|
onClick={() => void handleAddPayment()}
|
||||||
|
>
|
||||||
|
{addingPayment() ? copy().addingPayment : copy().paymentSaveAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().paymentMember}>
|
||||||
|
<Select
|
||||||
|
value={newPayment().memberId}
|
||||||
|
ariaLabel={copy().paymentMember}
|
||||||
|
placeholder="—"
|
||||||
|
options={[{ value: '', label: '—' }, ...memberOptions()]}
|
||||||
|
onChange={(memberId) => {
|
||||||
|
const member = dashboard()?.members.find((m) => m.memberId === memberId)
|
||||||
|
const prefill = computePaymentPrefill(member, newPayment().kind)
|
||||||
|
setNewPayment((p) => ({ ...p, memberId, amountMajor: prefill }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentKind}>
|
||||||
|
<Select
|
||||||
|
value={newPayment().kind}
|
||||||
|
ariaLabel={copy().paymentKind}
|
||||||
|
options={kindOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewPayment((p) => ({ ...p, kind: value as 'rent' | 'utilities' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newPayment().amountMajor}
|
||||||
|
onInput={(e) => setNewPayment((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().currencyLabel}>
|
||||||
|
<Select
|
||||||
|
value={newPayment().currency}
|
||||||
|
ariaLabel={copy().currencyLabel}
|
||||||
|
options={currencyOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewPayment((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ──────── Add Utility Modal ─────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={addUtilityOpen()}
|
||||||
|
title={copy().addUtilityBillAction}
|
||||||
|
description={copy().utilityBillCreateBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setAddUtilityOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setAddUtilityOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={addingUtility()}
|
||||||
|
disabled={!newUtility().billName.trim() || !newUtility().amountMajor.trim()}
|
||||||
|
onClick={() => void handleAddUtility()}
|
||||||
|
>
|
||||||
|
{addingUtility() ? copy().savingUtilityBill : copy().addUtilityBillAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().utilityCategoryLabel}>
|
||||||
|
<Input
|
||||||
|
value={newUtility().billName}
|
||||||
|
onInput={(e) => setNewUtility((p) => ({ ...p, billName: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().utilityAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newUtility().amountMajor}
|
||||||
|
onInput={(e) => setNewUtility((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().currencyLabel}>
|
||||||
|
<Select
|
||||||
|
value={newUtility().currency}
|
||||||
|
ariaLabel={copy().currencyLabel}
|
||||||
|
options={currencyOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setNewUtility((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ──────── Edit Utility Modal ────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!editingUtility()}
|
||||||
|
title={copy().editUtilityBillAction}
|
||||||
|
description={copy().utilityBillEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={closeUtilityEditor}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deletingUtility()}
|
||||||
|
onClick={() => void handleDeleteUtility()}
|
||||||
|
>
|
||||||
|
{deletingUtility() ? copy().deletingUtilityBill : copy().deleteUtilityBillAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={savingUtility()}
|
||||||
|
onClick={() => void handleSaveUtility()}
|
||||||
|
>
|
||||||
|
{savingUtility() ? copy().savingUtilityBill : copy().saveUtilityBillAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={utilityDraft()}>
|
||||||
|
{(draft) => (
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().utilityCategoryLabel}>
|
||||||
|
<Input
|
||||||
|
value={draft().billName}
|
||||||
|
onInput={(e) =>
|
||||||
|
setUtilityDraft((d) => (d ? { ...d, billName: e.currentTarget.value } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().utilityAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft().amountMajor}
|
||||||
|
onInput={(e) =>
|
||||||
|
setUtilityDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().currencyLabel}>
|
||||||
|
<Select
|
||||||
|
value={draft().currency}
|
||||||
|
ariaLabel={copy().currencyLabel}
|
||||||
|
options={currencyOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setUtilityDraft((d) => (d ? { ...d, currency: value as 'USD' | 'GEL' } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ──────── Edit Payment Modal ────────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!editingPayment()}
|
||||||
|
title={copy().editEntryAction}
|
||||||
|
description={copy().paymentEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={closePaymentEditor}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
loading={deletingPayment()}
|
||||||
|
onClick={() => void handleDeletePayment()}
|
||||||
|
>
|
||||||
|
{deletingPayment() ? copy().deletingPayment : copy().paymentDeleteAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={savingPayment()}
|
||||||
|
onClick={() => void handleSavePayment()}
|
||||||
|
>
|
||||||
|
{savingPayment() ? copy().savingPurchase : copy().paymentSaveAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show when={paymentDraftState()}>
|
||||||
|
{(draft) => (
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().paymentMember}>
|
||||||
|
<Select
|
||||||
|
value={draft().memberId}
|
||||||
|
ariaLabel={copy().paymentMember}
|
||||||
|
placeholder="—"
|
||||||
|
options={[{ value: '', label: '—' }, ...memberOptions()]}
|
||||||
|
onChange={(value) => setPaymentDraft((d) => (d ? { ...d, memberId: value } : d))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentKind}>
|
||||||
|
<Select
|
||||||
|
value={draft().kind}
|
||||||
|
ariaLabel={copy().paymentKind}
|
||||||
|
options={kindOptions()}
|
||||||
|
onChange={(value) =>
|
||||||
|
setPaymentDraft((d) => (d ? { ...d, kind: value as 'rent' | 'utilities' } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().paymentAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={draft().amountMajor}
|
||||||
|
onInput={(e) =>
|
||||||
|
setPaymentDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
618
apps/miniapp/src/routes/settings.tsx
Normal file
618
apps/miniapp/src/routes/settings.tsx
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import { Show, For, createSignal } from 'solid-js'
|
||||||
|
import { ArrowLeft, Globe, User } from 'lucide-solid'
|
||||||
|
import { useNavigate } from '@solidjs/router'
|
||||||
|
|
||||||
|
import { useSession } from '../contexts/session-context'
|
||||||
|
import { useI18n } from '../contexts/i18n-context'
|
||||||
|
import { useDashboard } from '../contexts/dashboard-context'
|
||||||
|
import { Card } from '../components/ui/card'
|
||||||
|
import { Button } from '../components/ui/button'
|
||||||
|
import { Badge } from '../components/ui/badge'
|
||||||
|
import { Select } from '../components/ui/select'
|
||||||
|
import { Input, Textarea } from '../components/ui/input'
|
||||||
|
import { Modal } from '../components/ui/dialog'
|
||||||
|
import { Collapsible } from '../components/ui/collapsible'
|
||||||
|
import { Field } from '../components/ui/field'
|
||||||
|
import {
|
||||||
|
updateMiniAppBillingSettings,
|
||||||
|
updateMiniAppMemberDisplayName,
|
||||||
|
updateMiniAppMemberRentWeight,
|
||||||
|
updateMiniAppMemberStatus,
|
||||||
|
promoteMiniAppMember,
|
||||||
|
approveMiniAppPendingMember,
|
||||||
|
rejectMiniAppPendingMember
|
||||||
|
} from '../miniapp-api'
|
||||||
|
import { minorToMajorString } from '../lib/money'
|
||||||
|
|
||||||
|
export default function SettingsRoute() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
readySession,
|
||||||
|
initData,
|
||||||
|
handleMemberLocaleChange,
|
||||||
|
displayNameDraft,
|
||||||
|
setDisplayNameDraft,
|
||||||
|
savingOwnDisplayName,
|
||||||
|
handleSaveOwnDisplayName
|
||||||
|
} = useSession()
|
||||||
|
const { copy, locale } = useI18n()
|
||||||
|
const {
|
||||||
|
effectiveIsAdmin,
|
||||||
|
adminSettings,
|
||||||
|
setAdminSettings,
|
||||||
|
cycleState,
|
||||||
|
pendingMembers,
|
||||||
|
setPendingMembers
|
||||||
|
} = useDashboard()
|
||||||
|
|
||||||
|
// ── Profile settings ─────────────────────────────
|
||||||
|
const [profileEditorOpen, setProfileEditorOpen] = createSignal(false)
|
||||||
|
|
||||||
|
// ── Billing settings form ────────────────────────
|
||||||
|
const [billingEditorOpen, setBillingEditorOpen] = createSignal(false)
|
||||||
|
const [savingSettings, setSavingSettings] = createSignal(false)
|
||||||
|
const [billingForm, setBillingForm] = createSignal({
|
||||||
|
householdName: adminSettings()?.householdName ?? '',
|
||||||
|
settlementCurrency: adminSettings()?.settings.settlementCurrency ?? 'GEL',
|
||||||
|
paymentBalanceAdjustmentPolicy:
|
||||||
|
adminSettings()?.settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
|
||||||
|
rentAmountMajor: adminSettings()
|
||||||
|
? minorToMajorString(BigInt(adminSettings()!.settings.rentAmountMinor ?? '0'))
|
||||||
|
: '',
|
||||||
|
rentCurrency: adminSettings()?.settings.rentCurrency ?? 'USD',
|
||||||
|
rentDueDay: adminSettings()?.settings.rentDueDay ?? 20,
|
||||||
|
rentWarningDay: adminSettings()?.settings.rentWarningDay ?? 17,
|
||||||
|
utilitiesDueDay: adminSettings()?.settings.utilitiesDueDay ?? 4,
|
||||||
|
utilitiesReminderDay: adminSettings()?.settings.utilitiesReminderDay ?? 3,
|
||||||
|
timezone: adminSettings()?.settings.timezone ?? 'Asia/Tbilisi',
|
||||||
|
assistantContext: adminSettings()?.assistantConfig?.assistantContext ?? '',
|
||||||
|
assistantTone: adminSettings()?.assistantConfig?.assistantTone ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Pending members ──────────────────────────────
|
||||||
|
const [approvingId, setApprovingId] = createSignal<string | null>(null)
|
||||||
|
const [rejectingId, setRejectingId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
async function handleApprove(telegramUserId: string) {
|
||||||
|
const data = initData()
|
||||||
|
if (!data || approvingId()) return
|
||||||
|
|
||||||
|
setApprovingId(telegramUserId)
|
||||||
|
try {
|
||||||
|
await approveMiniAppPendingMember(data, telegramUserId)
|
||||||
|
setPendingMembers((prev) => prev.filter((m) => m.telegramUserId !== telegramUserId))
|
||||||
|
} finally {
|
||||||
|
setApprovingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(telegramUserId: string) {
|
||||||
|
const data = initData()
|
||||||
|
if (!data || rejectingId()) return
|
||||||
|
|
||||||
|
setRejectingId(telegramUserId)
|
||||||
|
try {
|
||||||
|
await rejectMiniAppPendingMember(data, telegramUserId)
|
||||||
|
setPendingMembers((prev) => prev.filter((m) => m.telegramUserId !== telegramUserId))
|
||||||
|
} finally {
|
||||||
|
setRejectingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveSettings() {
|
||||||
|
const data = initData()
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
setSavingSettings(true)
|
||||||
|
try {
|
||||||
|
const { householdName, settings, assistantConfig } = await updateMiniAppBillingSettings(
|
||||||
|
data,
|
||||||
|
billingForm()
|
||||||
|
)
|
||||||
|
setAdminSettings((prev) =>
|
||||||
|
prev ? { ...prev, householdName, settings, assistantConfig } : prev
|
||||||
|
)
|
||||||
|
setBillingEditorOpen(false)
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Member Editing ──────────────────────────────
|
||||||
|
const [editMemberId, setEditMemberId] = createSignal<string | null>(null)
|
||||||
|
const [savingMember, setSavingMember] = createSignal(false)
|
||||||
|
const [editMemberForm, setEditMemberForm] = createSignal({
|
||||||
|
displayName: '',
|
||||||
|
rentShareWeight: 1,
|
||||||
|
status: 'active' as 'active' | 'away' | 'left',
|
||||||
|
isAdmin: false
|
||||||
|
})
|
||||||
|
|
||||||
|
function openEditMember(
|
||||||
|
member: NonNullable<ReturnType<typeof adminSettings>>['members'][number]
|
||||||
|
) {
|
||||||
|
setEditMemberId(member.id)
|
||||||
|
setEditMemberForm({
|
||||||
|
displayName: member.displayName,
|
||||||
|
rentShareWeight: member.rentShareWeight,
|
||||||
|
status: member.status,
|
||||||
|
isAdmin: member.isAdmin
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveMember() {
|
||||||
|
const data = initData()
|
||||||
|
const memberId = editMemberId()
|
||||||
|
const settings = adminSettings()
|
||||||
|
if (!data || !memberId || !settings) return
|
||||||
|
|
||||||
|
setSavingMember(true)
|
||||||
|
try {
|
||||||
|
const form = editMemberForm()
|
||||||
|
const currentMember = settings.members.find((m) => m.id === memberId)
|
||||||
|
if (!currentMember) return
|
||||||
|
|
||||||
|
let updatedMember = currentMember
|
||||||
|
|
||||||
|
// Update display name if changed
|
||||||
|
if (form.displayName !== currentMember.displayName) {
|
||||||
|
updatedMember = await updateMiniAppMemberDisplayName(data, memberId, form.displayName)
|
||||||
|
}
|
||||||
|
// Update rent weight if changed
|
||||||
|
if (form.rentShareWeight !== currentMember.rentShareWeight) {
|
||||||
|
updatedMember = await updateMiniAppMemberRentWeight(data, memberId, form.rentShareWeight)
|
||||||
|
}
|
||||||
|
// Update status if changed
|
||||||
|
if (form.status !== currentMember.status) {
|
||||||
|
updatedMember = await updateMiniAppMemberStatus(data, memberId, form.status)
|
||||||
|
}
|
||||||
|
// Promote to admin if requested and not already admin
|
||||||
|
if (form.isAdmin && !currentMember.isAdmin) {
|
||||||
|
updatedMember = await promoteMiniAppMember(data, memberId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setAdminSettings((prev) => {
|
||||||
|
if (!prev) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
members: prev.members.map((m) => (m.id === memberId ? updatedMember : m))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setEditMemberId(null)
|
||||||
|
} finally {
|
||||||
|
setSavingMember(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="route route--settings">
|
||||||
|
{/* ── Back + header ────────────────────────────── */}
|
||||||
|
<div class="settings-header">
|
||||||
|
<Button variant="ghost" size="sm" class="ui-button--very-left" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<h2>{effectiveIsAdmin() ? copy().householdSettingsTitle : copy().residentHouseTitle}</h2>
|
||||||
|
<p>{effectiveIsAdmin() ? copy().householdSettingsBody : copy().residentHouseBody}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Profile ──────────────────────────────────── */}
|
||||||
|
<Collapsible title={copy().houseSectionGeneral} body={copy().generalSettingsBody} defaultOpen>
|
||||||
|
<Card>
|
||||||
|
<div class="settings-profile">
|
||||||
|
<div
|
||||||
|
class="settings-profile__row interactive"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setProfileEditorOpen(true)}
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
<div>
|
||||||
|
<span class="settings-profile__label">{copy().displayNameLabel}</span>
|
||||||
|
<strong>{readySession()?.member.displayName}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-profile__row">
|
||||||
|
<Globe size={16} />
|
||||||
|
<div>
|
||||||
|
<span class="settings-profile__label">{copy().language}</span>
|
||||||
|
<div class="locale-switch locale-switch--compact">
|
||||||
|
<div class="locale-switch__buttons">
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'en' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleMemberLocaleChange('en')}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
classList={{ 'is-active': locale() === 'ru' }}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleMemberLocaleChange('ru')}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* ── Admin sections ───────────────────────────── */}
|
||||||
|
<Show when={effectiveIsAdmin()}>
|
||||||
|
{/* Billing settings */}
|
||||||
|
<Collapsible title={copy().houseSectionBilling} body={copy().billingSettingsTitle}>
|
||||||
|
<Card>
|
||||||
|
<Show when={adminSettings()}>
|
||||||
|
{(settings) => (
|
||||||
|
<div class="settings-billing-summary">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().householdNameLabel}</span>
|
||||||
|
<strong>{settings().householdName}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().settlementCurrency}</span>
|
||||||
|
<Badge variant="muted">{settings().settings.settlementCurrency}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().defaultRentAmount}</span>
|
||||||
|
<strong>
|
||||||
|
{minorToMajorString(BigInt(settings().settings.rentAmountMinor ?? '0'))}{' '}
|
||||||
|
{settings().settings.rentCurrency}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().timezone}</span>
|
||||||
|
<Badge variant="muted">{settings().settings.timezone}</Badge>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => setBillingEditorOpen(true)}>
|
||||||
|
{copy().manageSettingsAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Billing cycle */}
|
||||||
|
<Collapsible title={copy().billingCycleTitle}>
|
||||||
|
<Card>
|
||||||
|
<Show
|
||||||
|
when={cycleState()?.cycle}
|
||||||
|
fallback={<p class="empty-state">{copy().billingCycleEmpty}</p>}
|
||||||
|
>
|
||||||
|
{(cycle) => (
|
||||||
|
<div class="settings-billing-summary">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().billingCyclePeriod}</span>
|
||||||
|
<Badge variant="accent">{cycle().period}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span>{copy().currencyLabel}</span>
|
||||||
|
<Badge variant="muted">{cycle().currency}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Pending members */}
|
||||||
|
<Collapsible title={copy().pendingMembersTitle} body={copy().pendingMembersBody}>
|
||||||
|
<Show
|
||||||
|
when={pendingMembers().length > 0}
|
||||||
|
fallback={<p class="empty-state">{copy().pendingMembersEmpty}</p>}
|
||||||
|
>
|
||||||
|
<div class="pending-list">
|
||||||
|
<For each={pendingMembers()}>
|
||||||
|
{(member) => (
|
||||||
|
<Card>
|
||||||
|
<div class="pending-member-row">
|
||||||
|
<div>
|
||||||
|
<strong>{member.displayName}</strong>
|
||||||
|
<Show when={member.username}>
|
||||||
|
{(username) => (
|
||||||
|
<span class="pending-member-row__handle">@{username()}</span>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="pending-member-actions">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
loading={rejectingId() === member.telegramUserId}
|
||||||
|
disabled={approvingId() === member.telegramUserId}
|
||||||
|
onClick={() => void handleReject(member.telegramUserId)}
|
||||||
|
>
|
||||||
|
{rejectingId() === member.telegramUserId
|
||||||
|
? copy().rejectingMember
|
||||||
|
: copy().rejectMemberAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={approvingId() === member.telegramUserId}
|
||||||
|
disabled={rejectingId() === member.telegramUserId}
|
||||||
|
onClick={() => void handleApprove(member.telegramUserId)}
|
||||||
|
>
|
||||||
|
{approvingId() === member.telegramUserId
|
||||||
|
? copy().approvingMember
|
||||||
|
: copy().approveMemberAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Members */}
|
||||||
|
<Collapsible title={copy().houseSectionMembers} body={copy().membersBody}>
|
||||||
|
<Show when={adminSettings()?.members}>
|
||||||
|
{(members) => (
|
||||||
|
<div class="members-list">
|
||||||
|
<For each={members()}>
|
||||||
|
{(member) => (
|
||||||
|
<Card>
|
||||||
|
<div
|
||||||
|
class="member-row interactive"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openEditMember(member)}
|
||||||
|
>
|
||||||
|
<div class="member-row__info">
|
||||||
|
<strong>{member.displayName}</strong>
|
||||||
|
<div class="member-row__badges">
|
||||||
|
<Badge variant={member.isAdmin ? 'accent' : 'muted'}>
|
||||||
|
{member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="muted">
|
||||||
|
{member.status === 'active'
|
||||||
|
? copy().memberStatusActive
|
||||||
|
: member.status === 'away'
|
||||||
|
? copy().memberStatusAway
|
||||||
|
: copy().memberStatusLeft}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="member-row__weight">
|
||||||
|
<span>
|
||||||
|
{copy().rentWeightLabel}: {member.rentShareWeight}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Topic bindings */}
|
||||||
|
<Collapsible title={copy().houseSectionTopics} body={copy().topicBindingsBody}>
|
||||||
|
<Show when={adminSettings()?.topics}>
|
||||||
|
{(topics) => (
|
||||||
|
<div class="topics-list">
|
||||||
|
<For each={topics()}>
|
||||||
|
{(topic) => {
|
||||||
|
const roleLabel = () => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
purchase: copy().topicPurchase,
|
||||||
|
feedback: copy().topicFeedback,
|
||||||
|
reminders: copy().topicReminders,
|
||||||
|
payments: copy().topicPayments
|
||||||
|
}
|
||||||
|
return labels[topic.role] ?? topic.role
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div class="topic-row">
|
||||||
|
<span>{roleLabel()}</span>
|
||||||
|
<Badge variant={topic.telegramThreadId ? 'accent' : 'muted'}>
|
||||||
|
{topic.telegramThreadId ? copy().topicBound : copy().topicUnbound}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</Collapsible>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* ── Billing Settings Editor Modal ────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={billingEditorOpen()}
|
||||||
|
title={copy().billingSettingsTitle}
|
||||||
|
description={copy().billingSettingsEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setBillingEditorOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setBillingEditorOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={savingSettings()}
|
||||||
|
onClick={() => void handleSaveSettings()}
|
||||||
|
>
|
||||||
|
{savingSettings() ? copy().savingSettings : copy().saveSettingsAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().householdNameLabel} hint={copy().householdNameHint} wide>
|
||||||
|
<Input
|
||||||
|
value={billingForm().householdName}
|
||||||
|
onInput={(e) =>
|
||||||
|
setBillingForm((f) => ({ ...f, householdName: e.currentTarget.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().settlementCurrency}>
|
||||||
|
<Select
|
||||||
|
value={billingForm().settlementCurrency}
|
||||||
|
ariaLabel={copy().settlementCurrency}
|
||||||
|
options={[
|
||||||
|
{ value: 'GEL', label: 'GEL' },
|
||||||
|
{ value: 'USD', label: 'USD' }
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setBillingForm((f) => ({ ...f, settlementCurrency: value as 'USD' | 'GEL' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().defaultRentAmount}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={billingForm().rentAmountMajor}
|
||||||
|
onInput={(e) =>
|
||||||
|
setBillingForm((f) => ({ ...f, rentAmountMajor: e.currentTarget.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().timezone} hint={copy().timezoneHint}>
|
||||||
|
<Input
|
||||||
|
value={billingForm().timezone}
|
||||||
|
onInput={(e) => setBillingForm((f) => ({ ...f, timezone: e.currentTarget.value }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().assistantToneLabel} hint={copy().assistantTonePlaceholder}>
|
||||||
|
<Input
|
||||||
|
value={billingForm().assistantTone}
|
||||||
|
onInput={(e) =>
|
||||||
|
setBillingForm((f) => ({ ...f, assistantTone: e.currentTarget.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().assistantContextLabel} wide>
|
||||||
|
<Textarea
|
||||||
|
value={billingForm().assistantContext}
|
||||||
|
placeholder={copy().assistantContextPlaceholder}
|
||||||
|
onInput={(e) =>
|
||||||
|
setBillingForm((f) => ({ ...f, assistantContext: e.currentTarget.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Member Editor Modal ────────────── */}
|
||||||
|
<Modal
|
||||||
|
open={!!editMemberId()}
|
||||||
|
title={copy().inspectMemberTitle}
|
||||||
|
description={copy().memberEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setEditMemberId(null)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row">
|
||||||
|
<Button variant="ghost" onClick={() => setEditMemberId(null)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={savingMember()}
|
||||||
|
onClick={() => void handleSaveMember()}
|
||||||
|
>
|
||||||
|
{copy().saveMemberChangesAction}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().displayNameLabel} wide>
|
||||||
|
<Input
|
||||||
|
value={editMemberForm().displayName}
|
||||||
|
onInput={(e) =>
|
||||||
|
setEditMemberForm((f) => ({ ...f, displayName: e.currentTarget.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().rentWeightLabel}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={String(editMemberForm().rentShareWeight)}
|
||||||
|
onInput={(e) =>
|
||||||
|
setEditMemberForm((f) => ({
|
||||||
|
...f,
|
||||||
|
rentShareWeight: parseFloat(e.currentTarget.value) || 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={copy().memberStatusLabel}>
|
||||||
|
<Select
|
||||||
|
value={editMemberForm().status}
|
||||||
|
ariaLabel={copy().memberStatusLabel}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: copy().memberStatusActive },
|
||||||
|
{ value: 'away', label: copy().memberStatusAway },
|
||||||
|
{ value: 'left', label: copy().memberStatusLeft }
|
||||||
|
]}
|
||||||
|
onChange={(value) =>
|
||||||
|
setEditMemberForm((f) => ({ ...f, status: value as 'active' | 'away' | 'left' }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Show when={!editMemberForm().isAdmin}>
|
||||||
|
<Field label="Admin Access">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setEditMemberForm((f) => ({ ...f, isAdmin: true }))}
|
||||||
|
>
|
||||||
|
{copy().promoteAdminAction}
|
||||||
|
</Button>
|
||||||
|
</Field>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Own Profile Editor Modal ───────── */}
|
||||||
|
<Modal
|
||||||
|
open={profileEditorOpen()}
|
||||||
|
title={copy().displayNameLabel}
|
||||||
|
description={copy().profileEditorBody}
|
||||||
|
closeLabel={copy().closeEditorAction}
|
||||||
|
onClose={() => setProfileEditorOpen(false)}
|
||||||
|
footer={
|
||||||
|
<div class="modal-action-row modal-action-row--single">
|
||||||
|
<Button variant="ghost" onClick={() => setProfileEditorOpen(false)}>
|
||||||
|
{copy().closeEditorAction}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
savingOwnDisplayName() ||
|
||||||
|
displayNameDraft().trim().length < 2 ||
|
||||||
|
displayNameDraft().trim() === readySession()?.member.displayName
|
||||||
|
}
|
||||||
|
loading={savingOwnDisplayName()}
|
||||||
|
onClick={async () => {
|
||||||
|
await handleSaveOwnDisplayName()
|
||||||
|
setProfileEditorOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<Field label={copy().displayNameLabel} hint={copy().displayNameHint} wide>
|
||||||
|
<Input
|
||||||
|
value={displayNameDraft()}
|
||||||
|
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
import { Show } from 'solid-js'
|
|
||||||
|
|
||||||
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
|
|
||||||
import { FinanceVisuals } from '../components/finance/finance-visuals'
|
|
||||||
import { MemberBalanceCard } from '../components/finance/member-balance-card'
|
|
||||||
import { Field } from '../components/ui'
|
|
||||||
import { formatCyclePeriod } from '../lib/dates'
|
|
||||||
import type { MiniAppDashboard } from '../miniapp-api'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
copy: Record<string, string | undefined>
|
|
||||||
locale: 'en' | 'ru'
|
|
||||||
dashboard: MiniAppDashboard | null
|
|
||||||
currentMemberLine: MiniAppDashboard['members'][number] | null
|
|
||||||
inspectedMember: MiniAppDashboard['members'][number] | null
|
|
||||||
selectedMemberId: string
|
|
||||||
utilityTotalMajor: string
|
|
||||||
purchaseTotalMajor: string
|
|
||||||
memberBalanceVisuals: {
|
|
||||||
member: MiniAppDashboard['members'][number]
|
|
||||||
totalMinor: bigint
|
|
||||||
barWidthPercent: number
|
|
||||||
segments: {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
amountMajor: string
|
|
||||||
amountMinor: bigint
|
|
||||||
widthPercent: number
|
|
||||||
}[]
|
|
||||||
}[]
|
|
||||||
purchaseChart: {
|
|
||||||
totalMajor: string
|
|
||||||
slices: {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
amountMajor: string
|
|
||||||
color: string
|
|
||||||
percentage: number
|
|
||||||
dasharray: string
|
|
||||||
dashoffset: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string
|
|
||||||
memberRemainingClass: (member: MiniAppDashboard['members'][number]) => string
|
|
||||||
onSelectedMemberChange: (memberId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BalancesScreen(props: Props) {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.dashboard}
|
|
||||||
fallback={
|
|
||||||
<div class="balance-list">
|
|
||||||
<p>{props.copy.emptyDashboard ?? ''}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(dashboard) => (
|
|
||||||
<div class="balance-list">
|
|
||||||
<Show when={props.currentMemberLine}>
|
|
||||||
{(member) => (
|
|
||||||
<MemberBalanceCard
|
|
||||||
copy={props.copy}
|
|
||||||
locale={props.locale}
|
|
||||||
dashboard={dashboard()}
|
|
||||||
member={member()}
|
|
||||||
detail
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<section class="balance-item balance-item--wide balance-section balance-section--secondary">
|
|
||||||
<header class="balance-section__header">
|
|
||||||
<div class="balance-section__copy">
|
|
||||||
<strong>{props.copy.inspectMemberTitle ?? ''}</strong>
|
|
||||||
<p>{props.copy.inspectMemberBody ?? ''}</p>
|
|
||||||
</div>
|
|
||||||
<Field label={props.copy.inspectMemberLabel ?? ''} class="balance-section__field">
|
|
||||||
<select
|
|
||||||
value={props.selectedMemberId}
|
|
||||||
onChange={(event) => props.onSelectedMemberChange(event.currentTarget.value)}
|
|
||||||
>
|
|
||||||
{dashboard().members.map((member) => (
|
|
||||||
<option value={member.memberId}>{member.displayName}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Show when={props.inspectedMember}>
|
|
||||||
{(member) => (
|
|
||||||
<article class="balance-detail-card">
|
|
||||||
<header class="balance-detail-card__header">
|
|
||||||
<div class="balance-detail-card__copy">
|
|
||||||
<strong>{member().displayName}</strong>
|
|
||||||
<small>{formatCyclePeriod(dashboard().period, props.locale)}</small>
|
|
||||||
</div>
|
|
||||||
<span class={`balance-status ${props.memberRemainingClass(member())}`}>
|
|
||||||
{member().remainingMajor} {dashboard().currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="balance-detail-card__rows">
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.baseDue ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{props.memberBaseDueMajor(member())} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.shareRent ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().rentShareMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.shareUtilities ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().utilityShareMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.shareOffset ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().purchaseOffsetMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.paidLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().paidMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<article class="balance-detail-row balance-detail-row--accent">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().remainingMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<FinanceVisuals
|
|
||||||
dashboard={dashboard()}
|
|
||||||
memberVisuals={props.memberBalanceVisuals}
|
|
||||||
purchaseChart={props.purchaseChart}
|
|
||||||
remainingClass={props.memberRemainingClass}
|
|
||||||
labels={{
|
|
||||||
financeVisualsTitle: props.copy.financeVisualsTitle ?? '',
|
|
||||||
financeVisualsBody: props.copy.financeVisualsBody ?? '',
|
|
||||||
membersCount: props.copy.membersCount ?? '',
|
|
||||||
purchaseInvestmentsTitle: props.copy.purchaseInvestmentsTitle ?? '',
|
|
||||||
purchaseInvestmentsBody: props.copy.purchaseInvestmentsBody ?? '',
|
|
||||||
purchaseInvestmentsEmpty: props.copy.purchaseInvestmentsEmpty ?? '',
|
|
||||||
purchaseTotalLabel: props.copy.purchaseTotalLabel ?? '',
|
|
||||||
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<article class="balance-item balance-item--wide balance-item--muted">
|
|
||||||
<header>
|
|
||||||
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
|
|
||||||
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span>
|
|
||||||
</header>
|
|
||||||
<p>{props.copy.houseSnapshotBody ?? ''}</p>
|
|
||||||
<div class="summary-card-grid summary-card-grid--secondary">
|
|
||||||
<FinanceSummaryCards
|
|
||||||
dashboard={dashboard()}
|
|
||||||
utilityTotalMajor={props.utilityTotalMajor}
|
|
||||||
purchaseTotalMajor={props.purchaseTotalMajor}
|
|
||||||
labels={{
|
|
||||||
remaining: props.copy.remainingLabel ?? '',
|
|
||||||
rent: props.copy.shareRent ?? '',
|
|
||||||
utilities: props.copy.shareUtilities ?? '',
|
|
||||||
purchases: props.copy.purchasesTitle ?? ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,395 +0,0 @@
|
|||||||
import { Show } from 'solid-js'
|
|
||||||
|
|
||||||
import { Button } from '../components/ui'
|
|
||||||
import {
|
|
||||||
compareTodayToPeriodDay,
|
|
||||||
daysUntilPeriodDay,
|
|
||||||
formatCyclePeriod,
|
|
||||||
formatPeriodDay
|
|
||||||
} from '../lib/dates'
|
|
||||||
import { majorStringToMinor, minorToMajorString, sumMajorStrings } from '../lib/money'
|
|
||||||
import type { MiniAppDashboard } from '../miniapp-api'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
copy: Record<string, string | undefined>
|
|
||||||
locale: 'en' | 'ru'
|
|
||||||
dashboard: MiniAppDashboard | null
|
|
||||||
currentMemberLine: MiniAppDashboard['members'][number] | null
|
|
||||||
onExplainBalance: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type HomeMode = 'upcoming' | 'due' | 'settled'
|
|
||||||
|
|
||||||
export function HomeScreen(props: Props) {
|
|
||||||
const rentPaidMajor = () => {
|
|
||||||
if (!props.dashboard || !props.currentMemberLine) {
|
|
||||||
return '0.00'
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMinor = props.dashboard.ledger
|
|
||||||
.filter(
|
|
||||||
(entry) =>
|
|
||||||
entry.kind === 'payment' &&
|
|
||||||
entry.memberId === props.currentMemberLine?.memberId &&
|
|
||||||
entry.paymentKind === 'rent'
|
|
||||||
)
|
|
||||||
.reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
|
|
||||||
|
|
||||||
return minorToMajorString(totalMinor)
|
|
||||||
}
|
|
||||||
|
|
||||||
const utilitiesPaidMajor = () => {
|
|
||||||
if (!props.dashboard || !props.currentMemberLine) {
|
|
||||||
return '0.00'
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMinor = props.dashboard.ledger
|
|
||||||
.filter(
|
|
||||||
(entry) =>
|
|
||||||
entry.kind === 'payment' &&
|
|
||||||
entry.memberId === props.currentMemberLine?.memberId &&
|
|
||||||
entry.paymentKind === 'utilities'
|
|
||||||
)
|
|
||||||
.reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
|
|
||||||
|
|
||||||
return minorToMajorString(totalMinor)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasUtilityBills = () =>
|
|
||||||
Boolean(props.dashboard?.ledger.some((entry) => entry.kind === 'utility'))
|
|
||||||
|
|
||||||
const adjustedRentMajor = () => {
|
|
||||||
if (!props.currentMemberLine) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return sumMajorStrings(
|
|
||||||
props.currentMemberLine.rentShareMajor,
|
|
||||||
props.currentMemberLine.purchaseOffsetMajor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjustedUtilitiesMajor = () => {
|
|
||||||
if (!props.currentMemberLine) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return sumMajorStrings(
|
|
||||||
props.currentMemberLine.utilityShareMajor,
|
|
||||||
props.currentMemberLine.purchaseOffsetMajor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rentDueMajor = () => {
|
|
||||||
if (!props.currentMemberLine || !props.dashboard) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.dashboard.paymentBalanceAdjustmentPolicy === 'rent'
|
|
||||||
? adjustedRentMajor()
|
|
||||||
: props.currentMemberLine.rentShareMajor
|
|
||||||
}
|
|
||||||
|
|
||||||
const utilitiesDueMajor = () => {
|
|
||||||
if (!props.currentMemberLine || !props.dashboard || !hasUtilityBills()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'
|
|
||||||
? adjustedUtilitiesMajor()
|
|
||||||
: props.currentMemberLine.utilityShareMajor
|
|
||||||
}
|
|
||||||
|
|
||||||
const predictedUtilitiesMajor = () => {
|
|
||||||
if (!props.currentMemberLine) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.currentMemberLine.predictedUtilityShareMajor
|
|
||||||
}
|
|
||||||
|
|
||||||
const separateBalanceMajor = () => {
|
|
||||||
if (
|
|
||||||
!props.currentMemberLine ||
|
|
||||||
props.dashboard?.paymentBalanceAdjustmentPolicy !== 'separate'
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.currentMemberLine.purchaseOffsetMajor
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeMode = (): HomeMode => {
|
|
||||||
if (!props.dashboard || !props.currentMemberLine) {
|
|
||||||
return 'upcoming'
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingMinor = majorStringToMinor(props.currentMemberLine.remainingMajor)
|
|
||||||
const paidMinor = majorStringToMinor(props.currentMemberLine.paidMajor)
|
|
||||||
const rentStatus = compareTodayToPeriodDay(
|
|
||||||
props.dashboard.period,
|
|
||||||
props.dashboard.rentDueDay,
|
|
||||||
props.dashboard.timezone
|
|
||||||
)
|
|
||||||
const utilitiesStatus = compareTodayToPeriodDay(
|
|
||||||
props.dashboard.period,
|
|
||||||
props.dashboard.utilitiesDueDay,
|
|
||||||
props.dashboard.timezone
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasDueNow =
|
|
||||||
(rentStatus !== null &&
|
|
||||||
rentStatus >= 0 &&
|
|
||||||
majorStringToMinor(rentDueMajor() ?? '0.00') > 0n) ||
|
|
||||||
(utilitiesStatus !== null &&
|
|
||||||
utilitiesStatus >= 0 &&
|
|
||||||
majorStringToMinor(utilitiesDueMajor() ?? '0.00') > 0n) ||
|
|
||||||
(props.dashboard.paymentBalanceAdjustmentPolicy === 'separate' &&
|
|
||||||
majorStringToMinor(separateBalanceMajor() ?? '0.00') > 0n)
|
|
||||||
|
|
||||||
if (remainingMinor === 0n && paidMinor > 0n) {
|
|
||||||
return 'settled'
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasDueNow ? 'due' : 'upcoming'
|
|
||||||
}
|
|
||||||
|
|
||||||
const heroState = () => {
|
|
||||||
if (!props.dashboard || !props.currentMemberLine) {
|
|
||||||
return {
|
|
||||||
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
|
|
||||||
label: props.copy.remainingLabel ?? '',
|
|
||||||
amountMajor: '—'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (homeMode()) {
|
|
||||||
case 'settled':
|
|
||||||
return {
|
|
||||||
title: props.copy.homeSettledTitle ?? '',
|
|
||||||
label: props.copy.paidThisCycleLabel ?? props.copy.paidLabel ?? '',
|
|
||||||
amountMajor: props.currentMemberLine.paidMajor
|
|
||||||
}
|
|
||||||
case 'due':
|
|
||||||
return {
|
|
||||||
title: props.copy.homeDueTitle ?? props.copy.payNowTitle ?? '',
|
|
||||||
label: props.copy.remainingLabel ?? '',
|
|
||||||
amountMajor: props.currentMemberLine.remainingMajor
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
|
|
||||||
label: props.copy.cycleTotalLabel ?? props.copy.totalDue ?? '',
|
|
||||||
amountMajor: props.currentMemberLine.netDueMajor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayCountLabel = (daysLeft: number | null) => {
|
|
||||||
if (daysLeft === null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (daysLeft < 0) {
|
|
||||||
return props.copy.overdueLabel ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (daysLeft === 0) {
|
|
||||||
return props.copy.dueTodayLabel ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (props.copy.daysLeftLabel ?? '').replace('{count}', String(daysLeft))
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleLabel = (kind: 'rent' | 'utilities') => {
|
|
||||||
if (!props.dashboard) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const day = kind === 'rent' ? props.dashboard.rentDueDay : props.dashboard.utilitiesDueDay
|
|
||||||
const date = formatPeriodDay(props.dashboard.period, day, props.locale)
|
|
||||||
const daysLeft = daysUntilPeriodDay(props.dashboard.period, day, props.dashboard.timezone)
|
|
||||||
const dayLabel = dayCountLabel(daysLeft)
|
|
||||||
const dueLabel = (props.copy.dueOnLabel ?? '').replace('{date}', date)
|
|
||||||
|
|
||||||
return dayLabel ? `${dueLabel} · ${dayLabel}` : dueLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.dashboard}
|
|
||||||
fallback={
|
|
||||||
<div class="home-grid">
|
|
||||||
<article class="balance-item balance-item--accent balance-spotlight">
|
|
||||||
<header class="balance-spotlight__header">
|
|
||||||
<div class="balance-spotlight__copy">
|
|
||||||
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
|
|
||||||
</div>
|
|
||||||
<div class="balance-spotlight__hero">
|
|
||||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
|
||||||
<strong>—</strong>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(dashboard) => (
|
|
||||||
<div class="home-grid">
|
|
||||||
<Show when={props.currentMemberLine}>
|
|
||||||
{(member) => (
|
|
||||||
<article class="balance-item balance-item--accent home-pay-card">
|
|
||||||
<header class="home-pay-card__header">
|
|
||||||
<div class="home-pay-card__copy">
|
|
||||||
<strong>{heroState().title}</strong>
|
|
||||||
<small>{formatCyclePeriod(dashboard().period, props.locale)}</small>
|
|
||||||
</div>
|
|
||||||
<div class="home-pay-card__actions">
|
|
||||||
<Button variant="ghost" onClick={props.onExplainBalance}>
|
|
||||||
{props.copy.whyAction ?? ''}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="balance-spotlight__hero">
|
|
||||||
<span>{heroState().label}</span>
|
|
||||||
<strong>
|
|
||||||
{heroState().amountMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={homeMode() === 'upcoming'}
|
|
||||||
fallback={
|
|
||||||
<div class="balance-spotlight__stats">
|
|
||||||
<article class="stat-card balance-spotlight__stat">
|
|
||||||
<span>{props.copy.paidLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().paidMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card balance-spotlight__stat">
|
|
||||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().remainingMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="balance-spotlight__stats">
|
|
||||||
<article class="stat-card balance-spotlight__stat">
|
|
||||||
<span>{props.copy.shareRent ?? ''}</span>
|
|
||||||
<strong>{scheduleLabel('rent')}</strong>
|
|
||||||
</article>
|
|
||||||
<article class="stat-card balance-spotlight__stat">
|
|
||||||
<span>{props.copy.shareUtilities ?? ''}</span>
|
|
||||||
<strong>{scheduleLabel('utilities')}</strong>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<div class="balance-spotlight__rows">
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.shareRent ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{member().rentShareMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
<small>{scheduleLabel('rent')}</small>
|
|
||||||
</div>
|
|
||||||
<Show when={homeMode() !== 'upcoming'}>
|
|
||||||
<span class="mini-chip mini-chip--muted">
|
|
||||||
{props.copy.rentPaidLabel ?? props.copy.paidLabel}: {rentPaidMajor()}{' '}
|
|
||||||
{dashboard().currency}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>
|
|
||||||
{homeMode() === 'upcoming'
|
|
||||||
? (props.copy.expectedUtilitiesLabel ?? props.copy.shareUtilities)
|
|
||||||
: (props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities)}
|
|
||||||
</span>
|
|
||||||
<strong>
|
|
||||||
{homeMode() === 'upcoming'
|
|
||||||
? predictedUtilitiesMajor()
|
|
||||||
? `${predictedUtilitiesMajor()} ${dashboard().currency}`
|
|
||||||
: (props.copy.notBilledYetLabel ?? '')
|
|
||||||
: utilitiesDueMajor()
|
|
||||||
? `${member().utilityShareMajor} ${dashboard().currency}`
|
|
||||||
: (props.copy.notBilledYetLabel ?? '')}
|
|
||||||
</strong>
|
|
||||||
<small>{scheduleLabel('utilities')}</small>
|
|
||||||
</div>
|
|
||||||
<Show
|
|
||||||
when={
|
|
||||||
homeMode() !== 'upcoming' || majorStringToMinor(utilitiesPaidMajor()) > 0n
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="mini-chip mini-chip--muted">
|
|
||||||
{props.copy.utilitiesPaidLabel ?? props.copy.paidLabel}:{' '}
|
|
||||||
{utilitiesPaidMajor()} {dashboard().currency}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article class="balance-detail-row">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset}</span>
|
|
||||||
<strong>
|
|
||||||
{member().purchaseOffsetMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
<small>{props.copy.currentCycleLabel ?? ''}</small>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'rent'}>
|
|
||||||
<article class="balance-detail-row balance-detail-row--accent">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.rentAdjustedTotalLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{adjustedRentMajor()} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'utilities'}>
|
|
||||||
<article class="balance-detail-row balance-detail-row--accent">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
|
|
||||||
<strong>
|
|
||||||
{homeMode() === 'upcoming'
|
|
||||||
? predictedUtilitiesMajor()
|
|
||||||
? `${sumMajorStrings(
|
|
||||||
predictedUtilitiesMajor() ?? '0.00',
|
|
||||||
member().purchaseOffsetMajor
|
|
||||||
)} ${dashboard().currency}`
|
|
||||||
: (props.copy.notBilledYetLabel ?? '')
|
|
||||||
: `${adjustedUtilitiesMajor()} ${dashboard().currency}`}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'separate'}>
|
|
||||||
<article class="balance-detail-row balance-detail-row--accent">
|
|
||||||
<div class="balance-detail-row__main">
|
|
||||||
<span>{props.copy.finalDue ?? props.copy.remainingLabel}</span>
|
|
||||||
<strong>
|
|
||||||
{member().remainingMajor} {dashboard().currency}
|
|
||||||
</strong>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,636 +0,0 @@
|
|||||||
import { For, Show } from 'solid-js'
|
|
||||||
|
|
||||||
import { Button, Field, IconButton, Modal, PencilIcon, PlusIcon, TrashIcon } from '../components/ui'
|
|
||||||
import { formatFriendlyDate } from '../lib/dates'
|
|
||||||
import type { MiniAppAdminSettingsPayload, MiniAppDashboard } from '../miniapp-api'
|
|
||||||
|
|
||||||
type PurchaseDraft = {
|
|
||||||
description: string
|
|
||||||
amountMajor: string
|
|
||||||
currency: 'USD' | 'GEL'
|
|
||||||
splitMode: 'equal' | 'custom_amounts'
|
|
||||||
participants: {
|
|
||||||
memberId: string
|
|
||||||
shareAmountMajor: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaymentDraft = {
|
|
||||||
memberId: string
|
|
||||||
kind: 'rent' | 'utilities'
|
|
||||||
amountMajor: string
|
|
||||||
currency: 'USD' | 'GEL'
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
copy: Record<string, string | undefined>
|
|
||||||
locale: 'en' | 'ru'
|
|
||||||
dashboard: MiniAppDashboard | null
|
|
||||||
readyIsAdmin: boolean
|
|
||||||
adminMembers: readonly MiniAppAdminSettingsPayload['members'][number][]
|
|
||||||
purchaseEntries: readonly MiniAppDashboard['ledger'][number][]
|
|
||||||
utilityEntries: readonly MiniAppDashboard['ledger'][number][]
|
|
||||||
paymentEntries: readonly MiniAppDashboard['ledger'][number][]
|
|
||||||
editingPurchaseEntry: MiniAppDashboard['ledger'][number] | null
|
|
||||||
editingPaymentEntry: MiniAppDashboard['ledger'][number] | null
|
|
||||||
purchaseDraftMap: Record<string, PurchaseDraft>
|
|
||||||
paymentDraftMap: Record<string, PaymentDraft>
|
|
||||||
paymentForm: PaymentDraft
|
|
||||||
addingPaymentOpen: boolean
|
|
||||||
savingPurchaseId: string | null
|
|
||||||
deletingPurchaseId: string | null
|
|
||||||
savingPaymentId: string | null
|
|
||||||
deletingPaymentId: string | null
|
|
||||||
addingPayment: boolean
|
|
||||||
ledgerTitle: (entry: MiniAppDashboard['ledger'][number]) => string
|
|
||||||
ledgerPrimaryAmount: (entry: MiniAppDashboard['ledger'][number]) => string
|
|
||||||
ledgerSecondaryAmount: (entry: MiniAppDashboard['ledger'][number]) => string | null
|
|
||||||
purchaseParticipantSummary: (entry: MiniAppDashboard['ledger'][number]) => string
|
|
||||||
purchaseDraftForEntry: (entry: MiniAppDashboard['ledger'][number]) => PurchaseDraft
|
|
||||||
paymentDraftForEntry: (entry: MiniAppDashboard['ledger'][number]) => PaymentDraft
|
|
||||||
purchaseSplitPreview: (purchaseId: string) => { memberId: string; amountMajor: string }[]
|
|
||||||
paymentMemberName: (entry: MiniAppDashboard['ledger'][number]) => string
|
|
||||||
onOpenPurchaseEditor: (purchaseId: string) => void
|
|
||||||
onClosePurchaseEditor: () => void
|
|
||||||
onDeletePurchase: (purchaseId: string) => Promise<void>
|
|
||||||
onSavePurchase: (purchaseId: string) => Promise<void>
|
|
||||||
onPurchaseDescriptionChange: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: string
|
|
||||||
) => void
|
|
||||||
onPurchaseAmountChange: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: string
|
|
||||||
) => void
|
|
||||||
onPurchaseCurrencyChange: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: 'USD' | 'GEL'
|
|
||||||
) => void
|
|
||||||
onPurchaseSplitModeChange: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: 'equal' | 'custom_amounts'
|
|
||||||
) => void
|
|
||||||
onTogglePurchaseParticipant: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
memberId: string,
|
|
||||||
included: boolean
|
|
||||||
) => void
|
|
||||||
onPurchaseParticipantShareChange: (
|
|
||||||
purchaseId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
memberId: string,
|
|
||||||
value: string
|
|
||||||
) => void
|
|
||||||
onOpenAddPayment: () => void
|
|
||||||
onCloseAddPayment: () => void
|
|
||||||
onAddPayment: () => Promise<void>
|
|
||||||
onPaymentFormMemberChange: (value: string) => void
|
|
||||||
onPaymentFormKindChange: (value: 'rent' | 'utilities') => void
|
|
||||||
onPaymentFormAmountChange: (value: string) => void
|
|
||||||
onPaymentFormCurrencyChange: (value: 'USD' | 'GEL') => void
|
|
||||||
onOpenPaymentEditor: (paymentId: string) => void
|
|
||||||
onClosePaymentEditor: () => void
|
|
||||||
onDeletePayment: (paymentId: string) => Promise<void>
|
|
||||||
onSavePayment: (paymentId: string) => Promise<void>
|
|
||||||
onPaymentDraftMemberChange: (
|
|
||||||
paymentId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: string
|
|
||||||
) => void
|
|
||||||
onPaymentDraftKindChange: (
|
|
||||||
paymentId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: 'rent' | 'utilities'
|
|
||||||
) => void
|
|
||||||
onPaymentDraftAmountChange: (
|
|
||||||
paymentId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: string
|
|
||||||
) => void
|
|
||||||
onPaymentDraftCurrencyChange: (
|
|
||||||
paymentId: string,
|
|
||||||
entry: MiniAppDashboard['ledger'][number],
|
|
||||||
value: 'USD' | 'GEL'
|
|
||||||
) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LedgerScreen(props: Props) {
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.dashboard}
|
|
||||||
fallback={
|
|
||||||
<div class="ledger-list">
|
|
||||||
<p>{props.copy.emptyDashboard ?? ''}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="ledger-list">
|
|
||||||
<article class="balance-item">
|
|
||||||
<header>
|
|
||||||
<strong>
|
|
||||||
{props.readyIsAdmin ? props.copy.purchaseReviewTitle : props.copy.purchasesTitle}
|
|
||||||
</strong>
|
|
||||||
</header>
|
|
||||||
<Show when={props.readyIsAdmin}>
|
|
||||||
<p>{props.copy.purchaseReviewBody ?? ''}</p>
|
|
||||||
</Show>
|
|
||||||
{props.purchaseEntries.length === 0 ? (
|
|
||||||
<p>{props.copy.purchasesEmpty ?? ''}</p>
|
|
||||||
) : (
|
|
||||||
<div class="ledger-list">
|
|
||||||
<For each={props.purchaseEntries}>
|
|
||||||
{(entry) => (
|
|
||||||
<article class="ledger-compact-card">
|
|
||||||
<div class="ledger-compact-card__main">
|
|
||||||
<header>
|
|
||||||
<strong>{entry.title}</strong>
|
|
||||||
<span>
|
|
||||||
{entry.occurredAt
|
|
||||||
? formatFriendlyDate(entry.occurredAt, props.locale)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<p>{entry.actorDisplayName ?? props.copy.ledgerActorFallback ?? ''}</p>
|
|
||||||
<div class="ledger-compact-card__meta">
|
|
||||||
<span class="mini-chip">{props.ledgerPrimaryAmount(entry)}</span>
|
|
||||||
<Show when={props.ledgerSecondaryAmount(entry)}>
|
|
||||||
{(secondary) => (
|
|
||||||
<span class="mini-chip mini-chip--muted">{secondary()}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<span class="mini-chip mini-chip--muted">
|
|
||||||
{props.purchaseParticipantSummary(entry)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.readyIsAdmin}>
|
|
||||||
<div class="ledger-compact-card__actions">
|
|
||||||
<IconButton
|
|
||||||
label={props.copy.editEntryAction ?? ''}
|
|
||||||
onClick={() => props.onOpenPurchaseEditor(entry.id)}
|
|
||||||
>
|
|
||||||
<PencilIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
<Modal
|
|
||||||
open={Boolean(props.editingPurchaseEntry)}
|
|
||||||
title={props.copy.purchaseReviewTitle ?? ''}
|
|
||||||
description={props.copy.purchaseEditorBody ?? ''}
|
|
||||||
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
||||||
onClose={props.onClosePurchaseEditor}
|
|
||||||
footer={(() => {
|
|
||||||
const entry = props.editingPurchaseEntry
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="modal-action-row">
|
|
||||||
<IconButton
|
|
||||||
label={
|
|
||||||
props.deletingPurchaseId === entry.id
|
|
||||||
? (props.copy.deletingPurchase ?? '')
|
|
||||||
: (props.copy.purchaseDeleteAction ?? '')
|
|
||||||
}
|
|
||||||
class="ui-button--danger"
|
|
||||||
disabled={props.deletingPurchaseId === entry.id}
|
|
||||||
onClick={() => void props.onDeletePurchase(entry.id)}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</IconButton>
|
|
||||||
<div class="modal-action-row__primary">
|
|
||||||
<Button variant="ghost" onClick={props.onClosePurchaseEditor}>
|
|
||||||
{props.copy.closeEditorAction ?? ''}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={props.savingPurchaseId === entry.id}
|
|
||||||
onClick={() => void props.onSavePurchase(entry.id)}
|
|
||||||
>
|
|
||||||
{props.savingPurchaseId === entry.id
|
|
||||||
? props.copy.savingPurchase
|
|
||||||
: props.copy.purchaseSaveAction}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const entry = props.editingPurchaseEntry
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = props.purchaseDraftMap[entry.id] ?? props.purchaseDraftForEntry(entry)
|
|
||||||
const splitPreview = props.purchaseSplitPreview(entry.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="editor-grid">
|
|
||||||
<Field label={props.copy.purchaseReviewTitle ?? ''} wide>
|
|
||||||
<input
|
|
||||||
value={draft.description}
|
|
||||||
onInput={(event) =>
|
|
||||||
props.onPurchaseDescriptionChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
event.currentTarget.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.paymentAmount ?? ''}>
|
|
||||||
<input
|
|
||||||
value={draft.amountMajor}
|
|
||||||
onInput={(event) =>
|
|
||||||
props.onPurchaseAmountChange(entry.id, entry, event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
||||||
<select
|
|
||||||
value={draft.currency}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPurchaseCurrencyChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
event.currentTarget.value as 'USD' | 'GEL'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="GEL">GEL</option>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="editor-panel">
|
|
||||||
<header class="editor-panel__header">
|
|
||||||
<strong>{props.copy.purchaseSplitTitle ?? ''}</strong>
|
|
||||||
<span>
|
|
||||||
{draft.splitMode === 'custom_amounts'
|
|
||||||
? props.copy.purchaseSplitCustom
|
|
||||||
: props.copy.purchaseSplitEqual}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div class="editor-grid">
|
|
||||||
<Field label={props.copy.purchaseSplitModeLabel ?? ''} wide>
|
|
||||||
<select
|
|
||||||
value={draft.splitMode}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPurchaseSplitModeChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
event.currentTarget.value as 'equal' | 'custom_amounts'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="equal">{props.copy.purchaseSplitEqual ?? ''}</option>
|
|
||||||
<option value="custom_amounts">
|
|
||||||
{props.copy.purchaseSplitCustom ?? ''}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div class="participant-list">
|
|
||||||
<For each={props.adminMembers}>
|
|
||||||
{(member) => {
|
|
||||||
const included = draft.participants.some(
|
|
||||||
(participant) => participant.memberId === member.id
|
|
||||||
)
|
|
||||||
const previewAmount =
|
|
||||||
splitPreview.find((participant) => participant.memberId === member.id)
|
|
||||||
?.amountMajor ?? '0.00'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article class="participant-card">
|
|
||||||
<header>
|
|
||||||
<strong>{member.displayName}</strong>
|
|
||||||
<span>
|
|
||||||
{previewAmount} {draft.currency}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div class="participant-card__controls">
|
|
||||||
<Button
|
|
||||||
variant={included ? 'primary' : 'secondary'}
|
|
||||||
onClick={() =>
|
|
||||||
props.onTogglePurchaseParticipant(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
member.id,
|
|
||||||
!included
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{included
|
|
||||||
? props.copy.participantIncluded
|
|
||||||
: props.copy.participantExcluded}
|
|
||||||
</Button>
|
|
||||||
<Show when={included && draft.splitMode === 'custom_amounts'}>
|
|
||||||
<Field
|
|
||||||
label={props.copy.purchaseCustomShareLabel ?? ''}
|
|
||||||
class="participant-card__field"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={
|
|
||||||
draft.participants.find(
|
|
||||||
(participant) => participant.memberId === member.id
|
|
||||||
)?.shareAmountMajor ?? ''
|
|
||||||
}
|
|
||||||
onInput={(event) =>
|
|
||||||
props.onPurchaseParticipantShareChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
member.id,
|
|
||||||
event.currentTarget.value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Modal>
|
|
||||||
<article class="balance-item">
|
|
||||||
<header>
|
|
||||||
<strong>{props.copy.utilityLedgerTitle ?? ''}</strong>
|
|
||||||
</header>
|
|
||||||
{props.utilityEntries.length === 0 ? (
|
|
||||||
<p>{props.copy.utilityLedgerEmpty ?? ''}</p>
|
|
||||||
) : (
|
|
||||||
<div class="ledger-list">
|
|
||||||
<For each={props.utilityEntries}>
|
|
||||||
{(entry) => (
|
|
||||||
<article class="ledger-item">
|
|
||||||
<header>
|
|
||||||
<strong>{props.ledgerTitle(entry)}</strong>
|
|
||||||
<span>{props.ledgerPrimaryAmount(entry)}</span>
|
|
||||||
</header>
|
|
||||||
<Show when={props.ledgerSecondaryAmount(entry)}>
|
|
||||||
{(secondary) => <p>{secondary()}</p>}
|
|
||||||
</Show>
|
|
||||||
<p>{entry.actorDisplayName ?? props.copy.ledgerActorFallback ?? ''}</p>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
<article class="balance-item">
|
|
||||||
<header>
|
|
||||||
<strong>{props.copy.paymentsAdminTitle ?? ''}</strong>
|
|
||||||
</header>
|
|
||||||
<Show when={props.readyIsAdmin}>
|
|
||||||
<p>{props.copy.paymentsAdminBody ?? ''}</p>
|
|
||||||
<div class="panel-toolbar">
|
|
||||||
<Button variant="secondary" onClick={props.onOpenAddPayment}>
|
|
||||||
<PlusIcon />
|
|
||||||
{props.copy.paymentsAddAction ?? ''}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
{props.paymentEntries.length === 0 ? (
|
|
||||||
<p>{props.copy.paymentsEmpty ?? ''}</p>
|
|
||||||
) : (
|
|
||||||
<div class="ledger-list">
|
|
||||||
<For each={props.paymentEntries}>
|
|
||||||
{(entry) => (
|
|
||||||
<article class="ledger-compact-card">
|
|
||||||
<div class="ledger-compact-card__main">
|
|
||||||
<header>
|
|
||||||
<strong>{props.paymentMemberName(entry)}</strong>
|
|
||||||
<span>
|
|
||||||
{entry.occurredAt
|
|
||||||
? formatFriendlyDate(entry.occurredAt, props.locale)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<p>{props.ledgerTitle(entry)}</p>
|
|
||||||
<div class="ledger-compact-card__meta">
|
|
||||||
<span class="mini-chip">{props.ledgerPrimaryAmount(entry)}</span>
|
|
||||||
<Show when={props.ledgerSecondaryAmount(entry)}>
|
|
||||||
{(secondary) => (
|
|
||||||
<span class="mini-chip mini-chip--muted">{secondary()}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={props.readyIsAdmin}>
|
|
||||||
<div class="ledger-compact-card__actions">
|
|
||||||
<IconButton
|
|
||||||
label={props.copy.editEntryAction ?? ''}
|
|
||||||
onClick={() => props.onOpenPaymentEditor(entry.id)}
|
|
||||||
>
|
|
||||||
<PencilIcon />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</article>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
<Modal
|
|
||||||
open={props.addingPaymentOpen}
|
|
||||||
title={props.copy.paymentsAddAction ?? ''}
|
|
||||||
description={props.copy.paymentCreateBody ?? ''}
|
|
||||||
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
||||||
onClose={props.onCloseAddPayment}
|
|
||||||
footer={
|
|
||||||
<div class="modal-action-row modal-action-row--single">
|
|
||||||
<Button variant="ghost" onClick={props.onCloseAddPayment}>
|
|
||||||
{props.copy.closeEditorAction ?? ''}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={
|
|
||||||
props.addingPayment ||
|
|
||||||
props.paymentForm.memberId.trim().length === 0 ||
|
|
||||||
props.paymentForm.amountMajor.trim().length === 0
|
|
||||||
}
|
|
||||||
onClick={() => void props.onAddPayment()}
|
|
||||||
>
|
|
||||||
{props.addingPayment ? props.copy.addingPayment : props.copy.paymentsAddAction}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="editor-grid">
|
|
||||||
<Field label={props.copy.paymentMember ?? ''} wide>
|
|
||||||
<select
|
|
||||||
value={props.paymentForm.memberId}
|
|
||||||
onChange={(event) => props.onPaymentFormMemberChange(event.currentTarget.value)}
|
|
||||||
>
|
|
||||||
<For each={props.adminMembers}>
|
|
||||||
{(member) => <option value={member.id}>{member.displayName}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.paymentKind ?? ''}>
|
|
||||||
<select
|
|
||||||
value={props.paymentForm.kind}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPaymentFormKindChange(event.currentTarget.value as 'rent' | 'utilities')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="rent">{props.copy.paymentLedgerRent ?? ''}</option>
|
|
||||||
<option value="utilities">{props.copy.paymentLedgerUtilities ?? ''}</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.paymentAmount ?? ''}>
|
|
||||||
<input
|
|
||||||
value={props.paymentForm.amountMajor}
|
|
||||||
onInput={(event) => props.onPaymentFormAmountChange(event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
||||||
<select
|
|
||||||
value={props.paymentForm.currency}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPaymentFormCurrencyChange(event.currentTarget.value as 'USD' | 'GEL')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="GEL">GEL</option>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
<Modal
|
|
||||||
open={Boolean(props.editingPaymentEntry)}
|
|
||||||
title={props.copy.paymentsAdminTitle ?? ''}
|
|
||||||
description={props.copy.paymentEditorBody ?? ''}
|
|
||||||
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
||||||
onClose={props.onClosePaymentEditor}
|
|
||||||
footer={(() => {
|
|
||||||
const entry = props.editingPaymentEntry
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="modal-action-row">
|
|
||||||
<IconButton
|
|
||||||
label={
|
|
||||||
props.deletingPaymentId === entry.id
|
|
||||||
? (props.copy.deletingPayment ?? '')
|
|
||||||
: (props.copy.paymentDeleteAction ?? '')
|
|
||||||
}
|
|
||||||
class="ui-button--danger"
|
|
||||||
disabled={props.deletingPaymentId === entry.id}
|
|
||||||
onClick={() => void props.onDeletePayment(entry.id)}
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
|
||||||
</IconButton>
|
|
||||||
<div class="modal-action-row__primary">
|
|
||||||
<Button variant="ghost" onClick={props.onClosePaymentEditor}>
|
|
||||||
{props.copy.closeEditorAction ?? ''}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={props.savingPaymentId === entry.id}
|
|
||||||
onClick={() => void props.onSavePayment(entry.id)}
|
|
||||||
>
|
|
||||||
{props.savingPaymentId === entry.id
|
|
||||||
? props.copy.addingPayment
|
|
||||||
: props.copy.paymentSaveAction}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
const entry = props.editingPaymentEntry
|
|
||||||
|
|
||||||
if (!entry) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const draft = props.paymentDraftMap[entry.id] ?? props.paymentDraftForEntry(entry)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="editor-grid">
|
|
||||||
<Field label={props.copy.paymentMember ?? ''} wide>
|
|
||||||
<select
|
|
||||||
value={draft.memberId}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPaymentDraftMemberChange(entry.id, entry, event.currentTarget.value)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<For each={props.adminMembers}>
|
|
||||||
{(member) => <option value={member.id}>{member.displayName}</option>}
|
|
||||||
</For>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.paymentKind ?? ''}>
|
|
||||||
<select
|
|
||||||
value={draft.kind}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPaymentDraftKindChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
event.currentTarget.value as 'rent' | 'utilities'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="rent">{props.copy.paymentLedgerRent ?? ''}</option>
|
|
||||||
<option value="utilities">{props.copy.paymentLedgerUtilities ?? ''}</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.paymentAmount ?? ''}>
|
|
||||||
<input
|
|
||||||
value={draft.amountMajor}
|
|
||||||
onInput={(event) =>
|
|
||||||
props.onPaymentDraftAmountChange(entry.id, entry, event.currentTarget.value)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
||||||
<select
|
|
||||||
value={draft.currency}
|
|
||||||
onChange={(event) =>
|
|
||||||
props.onPaymentDraftCurrencyChange(
|
|
||||||
entry.id,
|
|
||||||
entry,
|
|
||||||
event.currentTarget.value as 'USD' | 'GEL'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="GEL">GEL</option>
|
|
||||||
<option value="USD">USD</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
90
apps/miniapp/src/theme.css
Normal file
90
apps/miniapp/src/theme.css
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Design tokens – Supabase-inspired dark theme with green accent
|
||||||
|
* Import this file before Tailwind or component styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ── Backgrounds ────────────────────────────────────── */
|
||||||
|
--bg-root: #1c1c1c;
|
||||||
|
--bg-surface: #242424;
|
||||||
|
--bg-elevated: #2e2e2e;
|
||||||
|
--bg-overlay: rgba(0, 0, 0, 0.75);
|
||||||
|
--bg-input: rgba(255, 255, 255, 0.05);
|
||||||
|
--bg-input-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
/* ── Accent ─────────────────────────────────────────── */
|
||||||
|
--accent: #3ecf8e;
|
||||||
|
--accent-hover: #36b87e;
|
||||||
|
--accent-soft: rgba(62, 207, 142, 0.2);
|
||||||
|
--accent-softer: rgba(62, 207, 142, 0.1);
|
||||||
|
--accent-border: rgba(62, 207, 142, 0.5);
|
||||||
|
|
||||||
|
/* ── Danger ─────────────────────────────────────────── */
|
||||||
|
--danger: #ff7676;
|
||||||
|
--danger-soft: rgba(255, 118, 118, 0.15);
|
||||||
|
--danger-border: rgba(255, 118, 118, 0.4);
|
||||||
|
|
||||||
|
/* ── Text ───────────────────────────────────────────── */
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #c2c2c2;
|
||||||
|
--text-muted: #999999;
|
||||||
|
--text-accent: #4ade80;
|
||||||
|
--text-danger: #ffc5c5;
|
||||||
|
|
||||||
|
/* ── Borders ────────────────────────────────────────── */
|
||||||
|
--border: rgba(255, 255, 255, 0.08);
|
||||||
|
--border-hover: rgba(255, 255, 255, 0.14);
|
||||||
|
--border-focus: rgba(62, 207, 142, 0.5);
|
||||||
|
|
||||||
|
/* ── Radii ──────────────────────────────────────────── */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-full: 999px;
|
||||||
|
|
||||||
|
/* ── Spacing ────────────────────────────────────────── */
|
||||||
|
--spacing-xs: 4px;
|
||||||
|
--spacing-sm: 8px;
|
||||||
|
--spacing-md: 12px;
|
||||||
|
--spacing-lg: 16px;
|
||||||
|
--spacing-xl: 20px;
|
||||||
|
--spacing-2xl: 24px;
|
||||||
|
|
||||||
|
/* ── Typography ─────────────────────────────────────── */
|
||||||
|
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
|
||||||
|
--text-xs: 0.72rem;
|
||||||
|
--text-sm: 0.82rem;
|
||||||
|
--text-base: 1rem;
|
||||||
|
--text-lg: 1.15rem;
|
||||||
|
--text-xl: 1.4rem;
|
||||||
|
--text-2xl: clamp(1.5rem, 4vw, 2rem);
|
||||||
|
--text-3xl: clamp(1.8rem, 5vw, 2.4rem);
|
||||||
|
|
||||||
|
/* ── Shadows ────────────────────────────────────────── */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-xl: 0 24px 64px rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
/* ── Transitions ────────────────────────────────────── */
|
||||||
|
--transition-fast: 120ms ease;
|
||||||
|
--transition-base: 180ms ease;
|
||||||
|
--transition-slow: 280ms ease;
|
||||||
|
|
||||||
|
/* ── Chart palette ──────────────────────────────────── */
|
||||||
|
--chart-1: #3ecf8e;
|
||||||
|
--chart-2: #6fd3c0;
|
||||||
|
--chart-3: #94a8ff;
|
||||||
|
--chart-4: #f06a8d;
|
||||||
|
--chart-5: #f3d36f;
|
||||||
|
--chart-6: #7dc96d;
|
||||||
|
|
||||||
|
/* ── Status colors ──────────────────────────────────── */
|
||||||
|
--status-credit: #4ade80;
|
||||||
|
--status-settled: #c2c2c2;
|
||||||
|
--status-due: #ffb866;
|
||||||
|
--status-overdue: #ff7676;
|
||||||
|
}
|
||||||
6
bun.lock
6
bun.lock
@@ -31,10 +31,12 @@
|
|||||||
"name": "@household/miniapp",
|
"name": "@household/miniapp",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
"@solidjs/router": "0.15.4",
|
||||||
"@tanstack/solid-query": "5.90.23",
|
"@tanstack/solid-query": "5.90.23",
|
||||||
"@twa-dev/sdk": "8.0.2",
|
"@twa-dev/sdk": "8.0.2",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"lucide-solid": "0.577.0",
|
||||||
"solid-js": "^1.9.9",
|
"solid-js": "^1.9.9",
|
||||||
"zod": "4.3.6",
|
"zod": "4.3.6",
|
||||||
},
|
},
|
||||||
@@ -386,6 +388,8 @@
|
|||||||
|
|
||||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
|
||||||
|
|
||||||
|
"@solidjs/router": ["@solidjs/router@0.15.4", "", { "peerDependencies": { "solid-js": "^1.8.6" } }, "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ=="],
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||||
|
|
||||||
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
|
"@t3-oss/env-core": ["@t3-oss/env-core@0.13.10", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g=="],
|
||||||
@@ -642,6 +646,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-solid": ["lucide-solid@0.577.0", "", { "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-r/rsauBlyNjFlUhXCkD544tOH1GgcFFupw9oP2zZT4BiFkHoO3MTr12QfKBrS5zCRIhktc/qY2tRr925hFlNuQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||||
|
|||||||
@@ -1291,6 +1291,20 @@ export function createDbHouseholdConfigurationRepository(databaseUrl: string): {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async rejectPendingHouseholdMember(input) {
|
||||||
|
const rows = await db
|
||||||
|
.delete(schema.householdPendingMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.householdPendingMembers.householdId, input.householdId),
|
||||||
|
eq(schema.householdPendingMembers.telegramUserId, input.telegramUserId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ telegramUserId: schema.householdPendingMembers.telegramUserId })
|
||||||
|
|
||||||
|
return rows.length > 0
|
||||||
|
},
|
||||||
|
|
||||||
async updateHouseholdDefaultLocale(householdId, locale) {
|
async updateHouseholdDefaultLocale(householdId, locale) {
|
||||||
const updatedHouseholds = await db
|
const updatedHouseholds = await db
|
||||||
.update(schema.households)
|
.update(schema.households)
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ function createRepositoryStub() {
|
|||||||
members.set(member.telegramUserId, member)
|
members.set(member.telegramUserId, member)
|
||||||
return member
|
return member
|
||||||
},
|
},
|
||||||
|
rejectPendingHouseholdMember: async (input) => {
|
||||||
|
return pendingMembers.delete(input.telegramUserId)
|
||||||
|
},
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
HouseholdTelegramChatRecord,
|
HouseholdTelegramChatRecord,
|
||||||
HouseholdTopicBindingRecord
|
HouseholdTopicBindingRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
import type { SupportedLocale } from '@household/domain'
|
||||||
|
|
||||||
import { createHouseholdOnboardingService } from './household-onboarding-service'
|
import { createHouseholdOnboardingService } from './household-onboarding-service'
|
||||||
|
|
||||||
@@ -141,7 +142,10 @@ function createRepositoryStub() {
|
|||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateHouseholdDefaultLocale(_householdId, locale) {
|
async rejectPendingHouseholdMember(input) {
|
||||||
|
return pendingMembers.delete(input.telegramUserId)
|
||||||
|
},
|
||||||
|
async updateHouseholdDefaultLocale(_householdId: string, locale: SupportedLocale) {
|
||||||
return {
|
return {
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
HouseholdTelegramChatRecord,
|
HouseholdTelegramChatRecord,
|
||||||
HouseholdTopicBindingRecord
|
HouseholdTopicBindingRecord
|
||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
import type { SupportedLocale } from '@household/domain'
|
||||||
|
|
||||||
import { createHouseholdSetupService } from './household-setup-service'
|
import { createHouseholdSetupService } from './household-setup-service'
|
||||||
|
|
||||||
@@ -228,7 +229,11 @@ function createRepositoryStub() {
|
|||||||
members.set(key, member)
|
members.set(key, member)
|
||||||
return member
|
return member
|
||||||
},
|
},
|
||||||
async updateHouseholdDefaultLocale(householdId, locale) {
|
async rejectPendingHouseholdMember(input) {
|
||||||
|
const key = `${input.householdId}:${input.telegramUserId}`
|
||||||
|
return pendingMembers.delete(key)
|
||||||
|
},
|
||||||
|
async updateHouseholdDefaultLocale(householdId: string, locale: SupportedLocale) {
|
||||||
const household =
|
const household =
|
||||||
[...households.values()].find((entry) => entry.householdId === householdId) ?? null
|
[...households.values()].find((entry) => entry.householdId === householdId) ?? null
|
||||||
if (!household) {
|
if (!household) {
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registerTelegramHouseholdChat: async () => ({ status: 'existing', household }),
|
registerTelegramHouseholdChat: async () => ({
|
||||||
|
status: 'existing',
|
||||||
|
household
|
||||||
|
}),
|
||||||
getTelegramHouseholdChat: async () => household,
|
getTelegramHouseholdChat: async () => household,
|
||||||
getHouseholdChatByHouseholdId: async () => household,
|
getHouseholdChatByHouseholdId: async () => household,
|
||||||
bindHouseholdTopic: async (input) => ({
|
bindHouseholdTopic: async (input) => ({
|
||||||
@@ -66,6 +69,7 @@ function createRepository(): HouseholdConfigurationRepository {
|
|||||||
listHouseholdMembersByTelegramUserId: async () => [member],
|
listHouseholdMembersByTelegramUserId: async () => [member],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => member,
|
approvePendingHouseholdMember: async () => member,
|
||||||
|
rejectPendingHouseholdMember: async () => false,
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
...household,
|
...household,
|
||||||
defaultLocale: locale
|
defaultLocale: locale
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function repository(): HouseholdConfigurationRepository {
|
|||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
rejectPendingHouseholdMember: async (input) => input.telegramUserId === '123456',
|
||||||
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
|
|||||||
@@ -110,6 +110,19 @@ export interface MiniAppAdminService {
|
|||||||
reason: 'not_admin' | 'pending_not_found'
|
reason: 'not_admin' | 'pending_not_found'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
rejectPendingMember(input: {
|
||||||
|
householdId: string
|
||||||
|
actorIsAdmin: boolean
|
||||||
|
pendingTelegramUserId: string
|
||||||
|
}): Promise<
|
||||||
|
| {
|
||||||
|
status: 'rejected_member'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: 'rejected'
|
||||||
|
reason: 'not_admin' | 'pending_not_found'
|
||||||
|
}
|
||||||
|
>
|
||||||
promoteMemberToAdmin(input: {
|
promoteMemberToAdmin(input: {
|
||||||
householdId: string
|
householdId: string
|
||||||
actorIsAdmin: boolean
|
actorIsAdmin: boolean
|
||||||
@@ -536,6 +549,31 @@ export function createMiniAppAdminService(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async rejectPendingMember(input) {
|
||||||
|
if (!input.actorIsAdmin) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'not_admin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await repository.rejectPendingHouseholdMember({
|
||||||
|
householdId: input.householdId,
|
||||||
|
telegramUserId: input.pendingTelegramUserId
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
status: 'rejected',
|
||||||
|
reason: 'pending_not_found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'rejected_member'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async promoteMemberToAdmin(input) {
|
async promoteMemberToAdmin(input) {
|
||||||
if (!input.actorIsAdmin) {
|
if (!input.actorIsAdmin) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ export interface HouseholdConfigurationRepository {
|
|||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
isAdmin?: boolean
|
isAdmin?: boolean
|
||||||
}): Promise<HouseholdMemberRecord | null>
|
}): Promise<HouseholdMemberRecord | null>
|
||||||
|
rejectPendingHouseholdMember(input: {
|
||||||
|
householdId: string
|
||||||
|
telegramUserId: string
|
||||||
|
}): Promise<boolean>
|
||||||
updateHouseholdDefaultLocale(
|
updateHouseholdDefaultLocale(
|
||||||
householdId: string,
|
householdId: string,
|
||||||
locale: SupportedLocale
|
locale: SupportedLocale
|
||||||
|
|||||||
Reference in New Issue
Block a user