Compare commits

...

2 Commits

Author SHA1 Message Date
Nico
52af2a755c ci: migrate deploy job to SSH compose and include scheduler service
Some checks failed
Build & Deploy Fiddy (SSH Compose) / build (push) Has been cancelled
Build & Deploy Fiddy (SSH Compose) / deploy (push) Has been cancelled
2026-02-21 23:52:36 -08:00
Nico
54c46dd5ac chore: checkpoint repo state before SSH CI/CD migration 2026-02-21 23:51:36 -08:00
50 changed files with 2789 additions and 1761 deletions

View File

@ -1,4 +1,4 @@
name: Build & Deploy Fiddy (Dokploy)
name: Build & Deploy Fiddy (SSH Compose)
on:
push:
@ -50,35 +50,40 @@ jobs:
deploy:
needs: build
runs-on: ubuntu-latest
env:
IMAGE_TAG: ${{ github.sha }}
DEPLOY_PATH: /opt/fiddy
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Trigger Dokploy Deploy
env:
DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }}
IMAGE_TAG: ${{ github.sha }}
- name: Install SSH key
run: |
if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then
echo "Missing DOKPLOY_DEPLOY_HOOK secret"
set -euo pipefail
if [ -z "${{ secrets.DEPLOY_KEY }}" ]; then
echo "Missing DEPLOY_KEY secret"
exit 1
fi
curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \
-H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
mkdir -p ~/.ssh
printf "%s" "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
- name: Trigger Dokploy Scheduler Deploy
env:
DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }}
IMAGE_TAG: ${{ github.sha }}
- name: Upload compose file
run: |
if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then
echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger"
exit 0
set -euo pipefail
if [ -z "${{ secrets.DEPLOY_HOST }}" ] || [ -z "${{ secrets.DEPLOY_USER }}" ]; then
echo "Missing DEPLOY_HOST or DEPLOY_USER secret"
exit 1
fi
curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \
-H "Content-Type: application/json" \
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "mkdir -p '$DEPLOY_PATH'"
scp docker-compose.yml "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:$DEPLOY_PATH/docker-compose.yml"
- name: Deploy via SSH Compose
run: |
set -euo pipefail
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \
"cd '$DEPLOY_PATH' && IMAGE_TAG='$IMAGE_TAG' docker compose pull && IMAGE_TAG='$IMAGE_TAG' docker compose up -d --remove-orphans && docker image prune -f"
- name: Wait for Ready Health Check
env:

View File

@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import { requireActiveGroup } from "@/lib/server/groups";
import GroupSettingsContent from "@/components/group-settings-content";
import GroupSettingsContent from "@/features/groups/components/group-settings-content";
export default async function GroupSettingsPage() {
const user = await getSessionUser();

View File

@ -1,7 +1,7 @@
import "./globals.css";
import type { Metadata } from "next";
import AppProviders from "@/components/app-providers";
import AppFrame from "@/components/app-frame";
import AppProviders from "@/features/app-shell/components/app-providers";
import AppFrame from "@/features/app-shell/components/app-frame";
export const metadata: Metadata = {
title: "Fiddy",

View File

@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import DashboardContent from "@/components/dashboard-content";
import DashboardContent from "@/features/dashboard/components/dashboard-content";
export default async function Page() {
const user = await getSessionUser();

View File

@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/server/session";
import SettingsContent from "@/components/settings-content";
import SettingsContent from "@/features/user-settings/components/settings-content";
export default async function SettingsPage() {
const user = await getSessionUser();

File diff suppressed because it is too large Load Diff

View File

@ -2,8 +2,17 @@
Domain-first frontend modules live here.
Current migrated domains:
- entries (components)
- buckets (components)
Current structure:
- `features/app-shell/components`: app frame, providers, navbar
- `features/dashboard/components`: dashboard composition
- `features/user-settings/components`: user settings UI
- `features/auth/hooks`: auth hook layer
- `features/groups/components` + `features/groups/hooks`: group settings UI and group APIs
- `features/entries/components` + `features/entries/hooks`: entries/schedules UI and APIs
- `features/buckets/components` + `features/buckets/hooks`: bucket UI and APIs
- `features/tags/hooks`: tag APIs
Future migrations should move domain-specific components/hooks/lib into these folders incrementally.
Rules:
- Put domain-owned UI under its domain folder.
- Keep hooks in the same domain whenever possible.
- Use `shared/*` only for cross-domain primitives.

View File

@ -2,7 +2,7 @@
import type React from "react";
import { usePathname } from "next/navigation";
import Navbar from "@/components/navbar";
import Navbar from "@/features/app-shell/components/navbar";
const NO_NAVBAR_PATHS = new Set(["/login", "/register"]);
const NO_NAVBAR_PREFIXES = ["/invite"];

View File

@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import GroupDropdown from "@/components/group-dropdown";
import GroupDropdown from "@/features/groups/components/group-dropdown";
import { useAuthContext } from "@/hooks/auth-context";
import { useGroupsContext } from "@/hooks/groups-context";
@ -113,7 +113,7 @@ export default function Navbar() {
onClick={() => setUserMenuOpen(prev => !prev)}
className="flex h-9 w-9 items-center justify-center rounded-full border border-accent-weak bg-panel text-sm text-muted hover:border-accent"
>
<span className="text-base">👤</span>
<span className="text-base">ðŸ¤</span>
</button>
{userMenuOpen ? (
<div className="absolute right-0 mt-2 w-48 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">

View File

@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from "react";
import { useGroupsContext } from "@/hooks/groups-context";
import useBuckets from "@/features/buckets/hooks/use-buckets";
import useTags from "@/features/tags/hooks/use-tags";
import NewBucketModal from "@/components/new-bucket-modal";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
import NewBucketModal from "@/features/buckets/components/new-bucket-modal";
import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import BucketCard from "./bucket-card";
import { useEntryMutation } from "@/hooks/entry-mutation-context";

View File

@ -2,9 +2,9 @@
import type React from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import TagInput from "@/components/tag-input";
import TagInput from "@/shared/components/forms/tag-input";
import { bucketIcons } from "@/lib/shared/bucket-icons";
import ToggleButtonGroup from "@/components/toggle-button-group";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
type BucketForm = {
name: string;
@ -70,7 +70,7 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
aria-label="Close"
>
âœ
</button>
<div className="text-lg font-semibold">{title}</div>
<form
@ -91,7 +91,7 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
className="flex h-10 w-12 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg"
onClick={() => setIconModalOpen(true)}
>
{selectedIcon || "🚫"}
{selectedIcon || "🚫"}
</div>
<input
name="name"
@ -192,7 +192,7 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
onClick={() => setIconModalOpen(false)}
aria-label="Close"
>
âœ
</button>
</div>
<input

View File

@ -1,8 +1,8 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import TagInput from "@/shared/components/forms/tag-input";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
export type EntriesFilters = {
amountMin: string;

View File

@ -0,0 +1,56 @@
"use client";
import type { EntryTab } from "@/features/entries/components/entries-panel.types";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
type EntriesPanelHeaderProps = {
entryTab: EntryTab;
activeGroupId: number | null;
activeFilterCount: number;
onTabChange: (tab: EntryTab) => void;
onOpenFilters: () => void;
onOpenCreate: () => void;
};
export default function EntriesPanelHeader({
entryTab,
activeGroupId,
activeFilterCount,
onTabChange,
onOpenFilters,
onOpenCreate
}: EntriesPanelHeaderProps) {
return (
<div className="card-header">
<ToggleButtonGroup
value={entryTab}
onChange={onTabChange}
ariaLabel="Entries and schedules tab"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
]}
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onOpenFilters}
className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50"
disabled={!activeGroupId}
>
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
</button>
<button
type="button"
onClick={onOpenCreate}
className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50"
disabled={!activeGroupId}
aria-label={entryTab === "ENTRIES" ? "Add entry" : "Add schedule"}
>
+
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,200 @@
"use client";
import type { Dispatch, FormEvent, MutableRefObject, SetStateAction } from "react";
import EntryDetailsModal from "@/features/entries/components/entry-details-modal";
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
import NewEntryModal from "@/features/entries/components/new-entry-modal";
import NewScheduleModal from "@/features/entries/components/new-schedule-modal";
import ScheduleDetailsModal from "@/features/entries/components/schedule-details-modal";
import type {
DeleteTarget,
EntryDetailsFormState,
EntryFormState,
ScheduleDetailsFormState,
ScheduleFormState
} from "@/features/entries/components/entries-panel.types";
import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal";
type EntriesPanelModalsProps = {
activeGroupId: number | null;
entriesError: string;
schedulesError: string;
tagSuggestions: string[];
canManageTags: boolean;
emptyTagActionLabel: string;
handleEmptyTagAction: () => void;
filterOpen: boolean;
setFilterOpen: Dispatch<SetStateAction<boolean>>;
filters: EntriesFilters;
setFilters: Dispatch<SetStateAction<EntriesFilters>>;
activeFilterCount: number;
clearFilters: () => void;
onFilterAddTag: (tag: string) => void;
onFilterToggleTag: (tag: string) => void;
newEntryOpen: boolean;
setNewEntryOpen: Dispatch<SetStateAction<boolean>>;
entryForm: EntryFormState;
setEntryForm: Dispatch<SetStateAction<EntryFormState>>;
submitNewEntry: (event: FormEvent<HTMLFormElement>) => Promise<void>;
amountInputRef: MutableRefObject<HTMLInputElement | null>;
tagsInputRef: MutableRefObject<HTMLInputElement | null>;
newScheduleOpen: boolean;
setNewScheduleOpen: Dispatch<SetStateAction<boolean>>;
scheduleForm: ScheduleFormState;
setScheduleForm: Dispatch<SetStateAction<ScheduleFormState>>;
submitNewSchedule: (event: FormEvent<HTMLFormElement>) => Promise<void>;
entryDetailsOpen: boolean;
setEntryDetailsOpen: Dispatch<SetStateAction<boolean>>;
entryDetailsForm: EntryDetailsFormState;
setEntryDetailsForm: Dispatch<SetStateAction<EntryDetailsFormState>>;
entryDetailsOriginal: EntryDetailsFormState | null;
hasEntryChanges: () => boolean;
submitEntryUpdate: (event: FormEvent<HTMLFormElement>) => Promise<void>;
entryRemovedTags: string[];
setEntryRemovedTags: Dispatch<SetStateAction<string[]>>;
prevEntry: () => void;
nextEntry: () => void;
selectedEntryIndex: number | null;
filteredEntriesCount: number;
scheduleDetailsOpen: boolean;
setScheduleDetailsOpen: Dispatch<SetStateAction<boolean>>;
scheduleDetailsForm: ScheduleDetailsFormState;
setScheduleDetailsForm: Dispatch<SetStateAction<ScheduleDetailsFormState>>;
scheduleDetailsOriginal: ScheduleDetailsFormState | null;
hasScheduleChanges: () => boolean;
submitScheduleUpdate: (event: FormEvent<HTMLFormElement>) => Promise<void>;
scheduleRemovedTags: string[];
setScheduleRemovedTags: Dispatch<SetStateAction<string[]>>;
confirmDeleteOpen: boolean;
setConfirmDeleteOpen: Dispatch<SetStateAction<boolean>>;
deleteTarget: DeleteTarget;
setDeleteTarget: Dispatch<SetStateAction<DeleteTarget>>;
confirmDelete: () => Promise<void>;
};
export default function EntriesPanelModals(props: EntriesPanelModalsProps) {
return (
<>
<NewEntryModal
isOpen={props.newEntryOpen && Boolean(props.activeGroupId)}
form={props.entryForm}
error={props.entriesError}
onClose={() => props.setNewEntryOpen(false)}
onSubmit={props.submitNewEntry}
onChange={next => props.setEntryForm(prev => ({ ...prev, ...next }))}
tagSuggestions={props.tagSuggestions}
emptyTagActionLabel={props.emptyTagActionLabel}
emptyTagActionDisabled={!props.canManageTags}
onEmptyTagAction={props.handleEmptyTagAction}
amountInputRef={props.amountInputRef}
tagsInputRef={props.tagsInputRef}
/>
<NewScheduleModal
isOpen={props.newScheduleOpen && Boolean(props.activeGroupId)}
form={props.scheduleForm}
error={props.schedulesError}
onClose={() => props.setNewScheduleOpen(false)}
onSubmit={props.submitNewSchedule}
onChange={next => props.setScheduleForm(prev => ({ ...prev, ...next }))}
tagSuggestions={props.tagSuggestions}
emptyTagActionLabel={props.emptyTagActionLabel}
emptyTagActionDisabled={!props.canManageTags}
onEmptyTagAction={props.handleEmptyTagAction}
/>
<EntryDetailsModal
isOpen={props.entryDetailsOpen}
form={props.entryDetailsForm}
originalForm={props.entryDetailsOriginal}
isDirty={props.hasEntryChanges()}
error={props.entriesError}
onClose={() => props.setEntryDetailsOpen(false)}
onSubmit={props.submitEntryUpdate}
onRequestDelete={() => {
props.setDeleteTarget("ENTRY");
props.setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!props.entryDetailsOriginal) return;
props.setEntryDetailsForm(props.entryDetailsOriginal);
props.setEntryRemovedTags([]);
}}
onChange={next => props.setEntryDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
props.setEntryDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
props.setEntryRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => props.setEntryRemovedTags(prev => (prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]))}
removedTags={props.entryRemovedTags}
tagSuggestions={props.tagSuggestions}
emptyTagActionLabel={props.emptyTagActionLabel}
emptyTagActionDisabled={!props.canManageTags}
onEmptyTagAction={props.handleEmptyTagAction}
onPrev={props.prevEntry}
onNext={props.nextEntry}
loopHintPrev={props.selectedEntryIndex === 0 && props.filteredEntriesCount > 1 ? "Loop" : ""}
loopHintNext={props.selectedEntryIndex === props.filteredEntriesCount - 1 && props.filteredEntriesCount > 1 ? "Loop" : ""}
canNavigate={props.filteredEntriesCount > 1}
/>
<ScheduleDetailsModal
isOpen={props.scheduleDetailsOpen}
form={props.scheduleDetailsForm}
originalForm={props.scheduleDetailsOriginal}
isDirty={props.hasScheduleChanges()}
error={props.schedulesError}
onClose={() => props.setScheduleDetailsOpen(false)}
onSubmit={props.submitScheduleUpdate}
onRequestDelete={() => {
props.setDeleteTarget("SCHEDULE");
props.setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!props.scheduleDetailsOriginal) return;
props.setScheduleDetailsForm(props.scheduleDetailsOriginal);
props.setScheduleRemovedTags([]);
}}
onChange={next => props.setScheduleDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
props.setScheduleDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
props.setScheduleRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => props.setScheduleRemovedTags(prev => (prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]))}
removedTags={props.scheduleRemovedTags}
tagSuggestions={props.tagSuggestions}
emptyTagActionLabel={props.emptyTagActionLabel}
emptyTagActionDisabled={!props.canManageTags}
onEmptyTagAction={props.handleEmptyTagAction}
/>
<EntriesFilterModal
isOpen={props.filterOpen}
filters={props.filters}
setFilters={props.setFilters}
activeFilterCount={props.activeFilterCount}
tagSuggestions={props.tagSuggestions}
canManageTags={props.canManageTags}
emptyTagActionLabel={props.emptyTagActionLabel}
onEmptyTagAction={props.handleEmptyTagAction}
onClearFilters={props.clearFilters}
onFilterAddTag={props.onFilterAddTag}
onFilterToggleTag={props.onFilterToggleTag}
onClose={() => props.setFilterOpen(false)}
/>
<ConfirmSlideModal
isOpen={props.confirmDeleteOpen}
title={props.deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
description={props.deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
confirmLabel={props.deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
onClose={() => props.setConfirmDeleteOpen(false)}
onConfirm={() => {
props.setConfirmDeleteOpen(false);
props.confirmDelete();
}}
/>
</>
);
}

View File

@ -1,58 +1,29 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import EntriesList from "@/features/entries/components/entries-list";
import EntriesPanelHeader from "@/features/entries/components/entries-panel-header";
import EntriesPanelModals from "@/features/entries/components/entries-panel-modals";
import SchedulesList from "@/features/entries/components/schedules-list";
import useEntriesPanelCrud from "@/features/entries/components/use-entries-panel-crud";
import useEntriesPanelFilters from "@/features/entries/components/use-entries-panel-filters";
import useEntries from "@/features/entries/hooks/use-entries";
import useSchedules from "@/features/entries/hooks/use-schedules";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useTags from "@/features/tags/hooks/use-tags";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
import { useEntryMutation } from "@/hooks/entry-mutation-context";
import useTags from "@/features/tags/hooks/use-tags";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useUserSettings from "@/hooks/use-user-settings";
import ToggleButtonGroup from "@/components/toggle-button-group";
import NewEntryModal from "@/components/new-entry-modal";
import EntryDetailsModal from "@/components/entry-details-modal";
import NewScheduleModal, { type NewScheduleForm } from "@/components/new-schedule-modal";
import ScheduleDetailsModal, { type ScheduleDetailsForm } from "@/components/schedule-details-modal";
import EntriesList from "@/features/entries/components/entries-list";
import SchedulesList from "@/features/entries/components/schedules-list";
import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal";
import ConfirmSlideModal from "@/components/confirm-slide-modal";
const EMPTY_FILTERS: EntriesFilters = {
amountMin: "",
amountMax: "",
dateFrom: "",
dateTo: "",
necessity: "ANY",
notesQuery: "",
tags: [],
tagsMode: "ANY"
};
function normalizeTagList(tags: string[]) {
return tags.map(tag => tag.toLowerCase()).sort().join("|");
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
function ListProgressSignal({
hasMore,
shownCount,
totalCount,
noun
}: {
type ListProgressSignalProps = {
hasMore: boolean;
shownCount: number;
totalCount: number;
noun: "entries" | "schedules";
}) {
};
function ListProgressSignal({ hasMore, shownCount, totalCount, noun }: ListProgressSignalProps) {
if (totalCount <= 0) return null;
return (
@ -73,7 +44,6 @@ function ListProgressSignal({
}
export default function EntriesPanel() {
const today = new Date().toISOString().slice(0, 10);
const { groups, activeGroupId } = useGroupsContext();
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
@ -82,7 +52,6 @@ export default function EntriesPanel() {
const { notifyEntryMutation } = useEntryMutation();
const { tags: tagSuggestions } = useTags(activeGroupId);
const { settings: groupSettings } = useGroupSettings(activeGroupId);
const router = useRouter();
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage));
@ -92,533 +61,111 @@ export default function EntriesPanel() {
const pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10));
const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES");
const [filters, setFilters] = useState<EntriesFilters>(EMPTY_FILTERS);
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
const [filterOpen, setFilterOpen] = useState(false);
const {
entryTab,
setEntryTab,
filters,
setFilters,
filterOpen,
setFilterOpen,
activeFilterCount,
filteredEntries,
filteredSchedules,
visibleEntries,
visibleSchedules,
hasMoreEntries,
hasMoreSchedules,
entriesLoadSentinelRef,
schedulesLoadSentinelRef,
clearFilters,
onFilterAddTag,
onFilterToggleTag
} = useEntriesPanelFilters({ entries, schedules, pageSize });
const [newEntryOpen, setNewEntryOpen] = useState(false);
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY");
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
const [entryForm, setEntryForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME"
const {
newEntryOpen,
setNewEntryOpen,
newScheduleOpen,
setNewScheduleOpen,
entryDetailsOpen,
setEntryDetailsOpen,
scheduleDetailsOpen,
setScheduleDetailsOpen,
confirmDeleteOpen,
setConfirmDeleteOpen,
deleteTarget,
setDeleteTarget,
selectedEntryIndex,
entryRemovedTags,
setEntryRemovedTags,
scheduleRemovedTags,
setScheduleRemovedTags,
entryForm,
setEntryForm,
entryDetailsForm,
setEntryDetailsForm,
entryDetailsOriginal,
scheduleForm,
setScheduleForm,
scheduleDetailsForm,
setScheduleDetailsForm,
scheduleDetailsOriginal,
amountInputRef,
tagsInputRef,
handleEmptyTagAction,
hasEntryChanges,
hasScheduleChanges,
submitNewEntry,
submitNewSchedule,
openEntryDetails,
openScheduleDetails,
submitEntryUpdate,
submitScheduleUpdate,
confirmDelete,
prevEntry,
nextEntry
} = useEntriesPanelCrud({
filteredEntries,
schedules,
createEntry,
updateEntry,
deleteEntry,
createSchedule,
updateSchedule,
deleteSchedule,
notify,
notifyEntryMutation,
canManageTags
});
const [entryDetailsForm, setEntryDetailsForm] = useState({
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [] as string[],
entryType: "SPENDING" as "SPENDING" | "INCOME"
});
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<typeof entryDetailsForm | null>(null);
const [scheduleForm, setScheduleForm] = useState<NewScheduleForm>({
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
createEntryNow: false
});
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsForm>({
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
nextRunOn: today,
isActive: true
});
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsForm | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(null);
const entriesLoadSentinelRef = useRef<HTMLDivElement>(null);
const schedulesLoadSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}, [pageSize]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.amountMin) count += 1;
if (filters.amountMax) count += 1;
if (filters.dateFrom) count += 1;
if (filters.dateTo) count += 1;
if (filters.necessity !== "ANY") count += 1;
if (filters.notesQuery.trim()) count += 1;
if (filters.tags.length) count += 1;
return count;
}, [filters]);
const filteredEntries = useMemo(() => {
const min = filters.amountMin ? Number(filters.amountMin) : null;
const max = filters.amountMax ? Number(filters.amountMax) : null;
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
const query = filters.notesQuery.trim().toLowerCase();
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
return entries.filter(entry => {
if (min != null && entry.amountDollars < min) return false;
if (max != null && entry.amountDollars > max) return false;
const time = new Date(entry.occurredAt).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
if (query && !(entry.notes || "").toLowerCase().includes(query)) return false;
if (tagsFilter.length) {
const entryTags = (entry.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") {
if (!tagsFilter.every(tag => entryTags.includes(tag))) return false;
} else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false;
}
return true;
});
}, [entries, filters]);
const filteredSchedules = useMemo(() => {
const min = filters.amountMin ? Number(filters.amountMin) : null;
const max = filters.amountMax ? Number(filters.amountMax) : null;
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
const query = filters.notesQuery.trim().toLowerCase();
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
return schedules.filter(schedule => {
if (min != null && schedule.amountDollars < min) return false;
if (max != null && schedule.amountDollars > max) return false;
const time = new Date(schedule.startsOn).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false;
if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false;
if (tagsFilter.length) {
const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") {
if (!tagsFilter.every(tag => scheduleTags.includes(tag))) return false;
} else if (!tagsFilter.some(tag => scheduleTags.includes(tag))) return false;
}
return true;
});
}, [schedules, filters]);
const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]);
const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]);
const hasMoreEntries = filteredEntries.length > visibleEntries.length;
const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length;
useEffect(() => {
if (entryTab !== "ENTRIES" || !hasMoreEntries) return;
let touchY: number | null = null;
let lastLoadAt = 0;
let lastScrollY = window.scrollY;
function shouldLoadMore() {
const sentinel = entriesLoadSentinelRef.current;
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return rect.top <= window.innerHeight + 48;
}
function tryLoadMore() {
if (!shouldLoadMore()) return;
const now = Date.now();
if (now - lastLoadAt < 150) return;
lastLoadAt = now;
setEntryVisibleCount(prev => {
if (prev >= filteredEntries.length) return prev;
return Math.min(prev + pageSize, filteredEntries.length);
});
}
function onWheel(event: WheelEvent) {
if (event.deltaY <= 0) return;
tryLoadMore();
}
function onScroll() {
const nextY = window.scrollY;
if (nextY <= lastScrollY) {
lastScrollY = nextY;
return;
}
lastScrollY = nextY;
tryLoadMore();
}
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
tryLoadMore();
}
}
function onTouchStart(event: TouchEvent) {
touchY = event.touches[0]?.clientY ?? null;
}
function onTouchMove(event: TouchEvent) {
const nextY = event.touches[0]?.clientY;
if (touchY == null || nextY == null) return;
const delta = touchY - nextY;
touchY = nextY;
if (delta > 10) tryLoadMore();
}
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
const listCounts = useMemo(() => {
return {
entriesShown: visibleEntries.length,
entriesTotal: filteredEntries.length,
schedulesShown: visibleSchedules.length,
schedulesTotal: filteredSchedules.length
};
}, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]);
useEffect(() => {
if (entryTab !== "SCHEDULES" || !hasMoreSchedules) return;
let touchY: number | null = null;
let lastLoadAt = 0;
let lastScrollY = window.scrollY;
function shouldLoadMore() {
const sentinel = schedulesLoadSentinelRef.current;
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return rect.top <= window.innerHeight + 48;
}
function tryLoadMore() {
if (!shouldLoadMore()) return;
const now = Date.now();
if (now - lastLoadAt < 150) return;
lastLoadAt = now;
setScheduleVisibleCount(prev => {
if (prev >= filteredSchedules.length) return prev;
return Math.min(prev + pageSize, filteredSchedules.length);
});
}
function onWheel(event: WheelEvent) {
if (event.deltaY <= 0) return;
tryLoadMore();
}
function onScroll() {
const nextY = window.scrollY;
if (nextY <= lastScrollY) {
lastScrollY = nextY;
return;
}
lastScrollY = nextY;
tryLoadMore();
}
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
tryLoadMore();
}
}
function onTouchStart(event: TouchEvent) {
touchY = event.touches[0]?.clientY ?? null;
}
function onTouchMove(event: TouchEvent) {
const nextY = event.touches[0]?.clientY;
if (touchY == null || nextY == null) return;
const delta = touchY - nextY;
touchY = nextY;
if (delta > 10) tryLoadMore();
}
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
};
}, [entryTab, hasMoreSchedules, filteredSchedules.length, pageSize]);
function clearFilters() {
setFilters(EMPTY_FILTERS);
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}
function handleEmptyTagAction() {
if (!canManageTags) return;
router.push("/groups/settings");
}
function hasEntryChanges() {
if (!entryDetailsOriginal) return false;
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
return (
entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars ||
entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt ||
entryDetailsForm.necessity !== entryDetailsOriginal.necessity ||
entryDetailsForm.notes !== entryDetailsOriginal.notes ||
entryDetailsForm.entryType !== entryDetailsOriginal.entryType ||
normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags)
);
}
function hasScheduleChanges() {
if (!scheduleDetailsOriginal) return false;
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
return (
scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars ||
scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn ||
scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity ||
scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes ||
scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType ||
scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency ||
scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount ||
scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition ||
scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount ||
scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate ||
scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn ||
scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive ||
normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags)
);
}
async function submitNewEntry(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!e.currentTarget.reportValidity()) return;
const amountDollars = Number(entryForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !entryForm.tags.length) return;
const created = await createEntry({
entryType: entryForm.entryType,
amountDollars,
occurredAt: entryForm.occurredAt,
necessity: entryForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType: entryForm.tags.join(", ") || "General",
notes: entryForm.notes.trim() || undefined,
tags: entryForm.tags
});
if (!created) return;
setNewEntryOpen(false);
setEntryForm({ amountDollars: "", occurredAt: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING" });
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
notifyEntryMutation();
}
async function submitNewSchedule(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const amountDollars = Number(scheduleForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !scheduleForm.tags.length) return;
const created = await createSchedule({
entryType: scheduleForm.entryType,
amountDollars,
startsOn: scheduleForm.startsOn,
necessity: scheduleForm.necessity,
purchaseType: scheduleForm.tags.join(", ") || "General",
notes: scheduleForm.notes.trim() || undefined,
tags: scheduleForm.tags,
frequency: scheduleForm.frequency,
intervalCount: scheduleForm.intervalCount,
endCondition: scheduleForm.endCondition,
endCount: scheduleForm.endCondition === "AFTER_COUNT" ? Number(scheduleForm.endCount || 0) || null : null,
endDate: scheduleForm.endCondition === "BY_DATE" ? scheduleForm.endDate || null : null,
createEntryNow: scheduleForm.createEntryNow
});
if (!created) return;
setNewScheduleOpen(false);
setScheduleForm({ amountDollars: "", startsOn: today, necessity: "NECESSARY", notes: "", tags: [], entryType: "SPENDING", frequency: "MONTHLY", intervalCount: 1, endCondition: "NEVER", endCount: "", endDate: "", createEntryNow: false });
notify({ title: "Schedule added", message: `${created.tags.join(", ") || "Schedule"} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
if (scheduleForm.createEntryNow) notifyEntryMutation();
}
function openEntryDetails(id: number) {
const index = filteredEntries.findIndex(entry => Number(entry.id) === Number(id));
if (index < 0) return;
const entry = filteredEntries[index];
const form = { amountDollars: String(entry.amountDollars), occurredAt: entry.occurredAt, necessity: entry.necessity, notes: entry.notes || "", tags: entry.tags || [], entryType: entry.entryType };
setSelectedEntryId(Number(id));
setSelectedEntryIndex(index);
setEntryDetailsForm(form);
setEntryDetailsOriginal(form);
setEntryRemovedTags([]);
setEntryDetailsOpen(true);
}
function openScheduleDetails(id: number) {
const schedule = schedules.find(item => Number(item.id) === Number(id));
if (!schedule) return;
const form: ScheduleDetailsForm = {
amountDollars: String(schedule.amountDollars),
startsOn: schedule.startsOn,
necessity: schedule.necessity,
notes: schedule.notes || "",
tags: schedule.tags || [],
entryType: schedule.entryType,
frequency: schedule.frequency,
intervalCount: schedule.intervalCount,
endCondition: schedule.endCondition,
endCount: schedule.endCount == null ? "" : String(schedule.endCount),
endDate: schedule.endDate || "",
nextRunOn: schedule.nextRunOn,
isActive: schedule.isActive
};
setSelectedScheduleId(Number(id));
setScheduleDetailsForm(form);
setScheduleDetailsOriginal(form);
setScheduleRemovedTags([]);
setScheduleDetailsOpen(true);
}
async function submitEntryUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedEntryId || !hasEntryChanges()) return;
const amount = Number(entryDetailsForm.amountDollars || 0);
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateEntry({
id: selectedEntryId,
entryType: entryDetailsForm.entryType,
amountDollars: amount,
occurredAt: entryDetailsForm.occurredAt,
necessity: entryDetailsForm.necessity as "NECESSARY" | "BOTH" | "UNNECESSARY",
purchaseType: tags.join(", ") || "General",
notes: entryDetailsForm.notes.trim() || undefined,
tags
});
if (!updated) return;
setEntryDetailsOpen(false);
notify({ title: "Entry updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
notifyEntryMutation();
}
async function submitScheduleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!selectedScheduleId || !hasScheduleChanges()) return;
const amount = Number(scheduleDetailsForm.amountDollars || 0);
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateSchedule({
id: selectedScheduleId,
entryType: scheduleDetailsForm.entryType,
amountDollars: amount,
startsOn: scheduleDetailsForm.startsOn,
necessity: scheduleDetailsForm.necessity,
purchaseType: tags.join(", ") || "General",
notes: scheduleDetailsForm.notes.trim() || undefined,
tags,
frequency: scheduleDetailsForm.frequency,
intervalCount: scheduleDetailsForm.intervalCount,
endCondition: scheduleDetailsForm.endCondition,
endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null,
endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null,
nextRunOn: scheduleDetailsForm.nextRunOn,
isActive: scheduleDetailsForm.isActive
});
if (!updated) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
}
async function confirmDelete() {
if (deleteTarget === "ENTRY" && selectedEntryId) {
const removed = await deleteEntry(selectedEntryId);
if (!removed) return;
setEntryDetailsOpen(false);
notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" });
notifyEntryMutation();
}
if (deleteTarget === "SCHEDULE" && selectedScheduleId) {
const removed = await deleteSchedule(selectedScheduleId);
if (!removed) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" });
}
}
function prevEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === 0 ? filteredEntries.length - 1 : current - 1;
openEntryDetails(filteredEntries[index].id);
}
function nextEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === filteredEntries.length - 1 ? 0 : current + 1;
openEntryDetails(filteredEntries[index].id);
}
}, [filteredEntries.length, filteredSchedules.length, visibleEntries.length, visibleSchedules.length]);
return (
<>
<div className="space-y-4">
<div className="panel panel-accent p-4">
<div className="card-header">
<ToggleButtonGroup
value={entryTab}
onChange={setEntryTab}
ariaLabel="Entries and schedules tab"
sizeClassName="px-3 py-2 text-xs font-semibold"
options={[
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
]}
<EntriesPanelHeader
entryTab={entryTab}
activeGroupId={activeGroupId}
activeFilterCount={activeFilterCount}
onTabChange={setEntryTab}
onOpenFilters={() => setFilterOpen(true)}
onOpenCreate={() => {
if (entryTab === "ENTRIES") {
setNewEntryOpen(true);
return;
}
setNewScheduleOpen(true);
}}
/>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setFilterOpen(true)} className="rounded-lg btn-outline-accent px-3 py-1 text-xs font-semibold disabled:opacity-50" disabled={!activeGroupId}>
Filters{activeFilterCount ? ` (${activeFilterCount})` : ""}
</button>
<button type="button" onClick={() => entryTab === "ENTRIES" ? setNewEntryOpen(true) : setNewScheduleOpen(true)} className="flex h-8 w-8 -translate-y-0.5 items-center justify-center rounded-full border border-accent bg-panel text-lg font-semibold leading-none hover:border-accent-strong disabled:opacity-50" disabled={!activeGroupId} aria-label={entryTab === "ENTRIES" ? "Add entry" : "Add schedule"}>
+
</button>
</div>
</div>
{entryTab === "ENTRIES" ? (
<>
<EntriesList
@ -633,8 +180,8 @@ export default function EntriesPanel() {
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
<ListProgressSignal
hasMore={hasMoreEntries}
shownCount={visibleEntries.length}
totalCount={filteredEntries.length}
shownCount={listCounts.entriesShown}
totalCount={listCounts.entriesTotal}
noun="entries"
/>
</>
@ -652,127 +199,70 @@ export default function EntriesPanel() {
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
<ListProgressSignal
hasMore={hasMoreSchedules}
shownCount={visibleSchedules.length}
totalCount={filteredSchedules.length}
shownCount={listCounts.schedulesShown}
totalCount={listCounts.schedulesTotal}
noun="schedules"
/>
</>
)}
</div>
</div>
<NewEntryModal
isOpen={newEntryOpen && Boolean(activeGroupId)}
form={entryForm}
error={entriesError}
onClose={() => setNewEntryOpen(false)}
onSubmit={submitNewEntry}
onChange={next => setEntryForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
amountInputRef={amountInputRef}
tagsInputRef={tagsInputRef}
/>
<NewScheduleModal
isOpen={newScheduleOpen && Boolean(activeGroupId)}
form={scheduleForm}
error={schedulesError}
onClose={() => setNewScheduleOpen(false)}
onSubmit={submitNewSchedule}
onChange={next => setScheduleForm(prev => ({ ...prev, ...next }))}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
/>
<EntryDetailsModal
isOpen={entryDetailsOpen}
form={entryDetailsForm}
originalForm={entryDetailsOriginal}
isDirty={hasEntryChanges()}
error={entriesError}
onClose={() => setEntryDetailsOpen(false)}
onSubmit={submitEntryUpdate}
onRequestDelete={() => {
setDeleteTarget("ENTRY");
setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!entryDetailsOriginal) return;
setEntryDetailsForm(entryDetailsOriginal);
setEntryRemovedTags([]);
}}
onChange={next => setEntryDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
setEntryDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
setEntryRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => setEntryRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
removedTags={entryRemovedTags}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
onPrev={prevEntry}
onNext={nextEntry}
loopHintPrev={selectedEntryIndex === 0 && filteredEntries.length > 1 ? "Loop" : ""}
loopHintNext={selectedEntryIndex === filteredEntries.length - 1 && filteredEntries.length > 1 ? "Loop" : ""}
canNavigate={filteredEntries.length > 1}
/>
<ScheduleDetailsModal
isOpen={scheduleDetailsOpen}
form={scheduleDetailsForm}
originalForm={scheduleDetailsOriginal}
isDirty={hasScheduleChanges()}
error={schedulesError}
onClose={() => setScheduleDetailsOpen(false)}
onSubmit={submitScheduleUpdate}
onRequestDelete={() => {
setDeleteTarget("SCHEDULE");
setConfirmDeleteOpen(true);
}}
onRevert={() => {
if (!scheduleDetailsOriginal) return;
setScheduleDetailsForm(scheduleDetailsOriginal);
setScheduleRemovedTags([]);
}}
onChange={next => setScheduleDetailsForm(prev => ({ ...prev, ...next }))}
onAddTag={tag => {
setScheduleDetailsForm(prev => ({ ...prev, tags: prev.tags.includes(tag) ? prev.tags : [...prev.tags, tag] }));
setScheduleRemovedTags(prev => prev.filter(item => item !== tag));
}}
onToggleTag={tag => setScheduleRemovedTags(prev => prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag])}
removedTags={scheduleRemovedTags}
tagSuggestions={tagSuggestions}
emptyTagActionLabel={emptyTagActionLabel}
emptyTagActionDisabled={!canManageTags}
onEmptyTagAction={handleEmptyTagAction}
/>
<EntriesFilterModal
isOpen={filterOpen}
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
<EntriesPanelModals
activeGroupId={activeGroupId}
entriesError={entriesError}
schedulesError={schedulesError}
tagSuggestions={tagSuggestions}
canManageTags={canManageTags}
emptyTagActionLabel={emptyTagActionLabel}
onEmptyTagAction={handleEmptyTagAction}
onClearFilters={clearFilters}
onFilterAddTag={tag => setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))}
onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))}
onClose={() => setFilterOpen(false)}
/>
<ConfirmSlideModal
isOpen={confirmDeleteOpen}
title={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
description={deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
confirmLabel={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={() => {
setConfirmDeleteOpen(false);
confirmDelete();
}}
handleEmptyTagAction={handleEmptyTagAction}
filterOpen={filterOpen}
setFilterOpen={setFilterOpen}
filters={filters}
setFilters={setFilters}
activeFilterCount={activeFilterCount}
clearFilters={clearFilters}
onFilterAddTag={onFilterAddTag}
onFilterToggleTag={onFilterToggleTag}
newEntryOpen={newEntryOpen}
setNewEntryOpen={setNewEntryOpen}
entryForm={entryForm}
setEntryForm={setEntryForm}
submitNewEntry={submitNewEntry}
amountInputRef={amountInputRef}
tagsInputRef={tagsInputRef}
newScheduleOpen={newScheduleOpen}
setNewScheduleOpen={setNewScheduleOpen}
scheduleForm={scheduleForm}
setScheduleForm={setScheduleForm}
submitNewSchedule={submitNewSchedule}
entryDetailsOpen={entryDetailsOpen}
setEntryDetailsOpen={setEntryDetailsOpen}
entryDetailsForm={entryDetailsForm}
setEntryDetailsForm={setEntryDetailsForm}
entryDetailsOriginal={entryDetailsOriginal}
hasEntryChanges={hasEntryChanges}
submitEntryUpdate={submitEntryUpdate}
entryRemovedTags={entryRemovedTags}
setEntryRemovedTags={setEntryRemovedTags}
prevEntry={prevEntry}
nextEntry={nextEntry}
selectedEntryIndex={selectedEntryIndex}
filteredEntriesCount={filteredEntries.length}
scheduleDetailsOpen={scheduleDetailsOpen}
setScheduleDetailsOpen={setScheduleDetailsOpen}
scheduleDetailsForm={scheduleDetailsForm}
setScheduleDetailsForm={setScheduleDetailsForm}
scheduleDetailsOriginal={scheduleDetailsOriginal}
hasScheduleChanges={hasScheduleChanges}
submitScheduleUpdate={submitScheduleUpdate}
scheduleRemovedTags={scheduleRemovedTags}
setScheduleRemovedTags={setScheduleRemovedTags}
confirmDeleteOpen={confirmDeleteOpen}
setConfirmDeleteOpen={setConfirmDeleteOpen}
deleteTarget={deleteTarget}
setDeleteTarget={setDeleteTarget}
confirmDelete={confirmDelete}
/>
</>
);

View File

@ -0,0 +1,23 @@
"use client";
import type { EntryDetailsForm } from "@/features/entries/components/entry-details-modal";
import type { EntriesFilters } from "@/features/entries/components/entries-filter-modal";
import type { NewScheduleForm } from "@/features/entries/components/new-schedule-modal";
import type { ScheduleDetailsForm } from "@/features/entries/components/schedule-details-modal";
export type EntryTab = "ENTRIES" | "SCHEDULES";
export type DeleteTarget = "ENTRY" | "SCHEDULE";
export type EntryFormState = {
amountDollars: string;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";
};
export type EntryDetailsFormState = EntryDetailsForm;
export type ScheduleFormState = NewScheduleForm;
export type ScheduleDetailsFormState = ScheduleDetailsForm;
export type EntriesPanelFilters = EntriesFilters;

View File

@ -0,0 +1,31 @@
"use client";
import type { EntriesFilters } from "@/features/entries/components/entries-filter-modal";
export function getTodayIsoDate() {
return new Date().toISOString().slice(0, 10);
}
export function normalizeTagList(tags: string[]) {
return tags.map(tag => tag.toLowerCase()).sort().join("|");
}
export function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
}
export function createEmptyEntriesFilters(): EntriesFilters {
return {
amountMin: "",
amountMax: "",
dateFrom: "",
dateTo: "",
necessity: "ANY",
notesQuery: "",
tags: [],
tagsMode: "ANY"
};
}

View File

@ -2,14 +2,14 @@
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/components/date-picker";
import TagInput from "@/shared/components/forms/tag-input";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
import DatePicker from "@/shared/components/forms/date-picker";
export type EntryDetailsForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";

View File

@ -2,14 +2,14 @@
import type React from "react";
import { useEffect, useRef } from "react";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/components/date-picker";
import TagInput from "@/shared/components/forms/tag-input";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
import DatePicker from "@/shared/components/forms/date-picker";
type NewEntryForm = {
amountDollars: string;
occurredAt: string;
necessity: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
notes: string;
tags: string[];
entryType: "SPENDING" | "INCOME";

View File

@ -2,9 +2,9 @@
import type React from "react";
import { useEffect, useRef } from "react";
import DatePicker from "@/components/date-picker";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/shared/components/forms/date-picker";
import TagInput from "@/shared/components/forms/tag-input";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export type NewScheduleForm = {

View File

@ -2,9 +2,9 @@
import type React from "react";
import { useEffect, useRef } from "react";
import DatePicker from "@/components/date-picker";
import TagInput from "@/components/tag-input";
import ToggleButtonGroup from "@/components/toggle-button-group";
import DatePicker from "@/shared/components/forms/date-picker";
import TagInput from "@/shared/components/forms/tag-input";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
export type ScheduleDetailsForm = {

View File

@ -0,0 +1,407 @@
"use client";
import { useRef, useState } from "react";
import type { FormEvent } from "react";
import { useRouter } from "next/navigation";
import type { Entry, Schedule } from "@/lib/shared/types";
import type {
DeleteTarget,
EntryDetailsFormState,
EntryFormState,
ScheduleDetailsFormState,
ScheduleFormState
} from "@/features/entries/components/entries-panel.types";
import { getTodayIsoDate, normalizeTagList } from "@/features/entries/components/entries-panel.utils";
type CreateEntryInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
occurredAt: string;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
bucketId?: number | null;
};
type UpdateEntryInput = CreateEntryInput & { id: number };
type ScheduleInput = {
entryType: "SPENDING" | "INCOME";
amountDollars: number;
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
purchaseType: string;
notes?: string;
tags?: string[];
startsOn: string;
frequency: "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY";
intervalCount?: number;
endCondition?: "NEVER" | "AFTER_COUNT" | "BY_DATE";
endCount?: number | null;
endDate?: string | null;
};
type NotifyInput = {
title: string;
message?: string;
tone?: "info" | "success" | "danger";
durationMs?: number;
};
type UseEntriesPanelCrudParams = {
filteredEntries: Entry[];
schedules: Schedule[];
createEntry: (input: CreateEntryInput) => Promise<Entry | null>;
updateEntry: (input: UpdateEntryInput) => Promise<Entry | null>;
deleteEntry: (id: number | string) => Promise<Entry | null>;
createSchedule: (input: ScheduleInput & { createEntryNow?: boolean }) => Promise<Schedule | null>;
updateSchedule: (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }) => Promise<Schedule | null>;
deleteSchedule: (id: number | string) => Promise<Schedule | null>;
notify: (input: NotifyInput) => void;
notifyEntryMutation: () => void;
canManageTags: boolean;
};
function createInitialEntryForm(today: string): EntryFormState {
return {
amountDollars: "",
occurredAt: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING"
};
}
function createInitialScheduleForm(today: string): ScheduleFormState {
return {
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
createEntryNow: false
};
}
function createInitialScheduleDetailsForm(today: string): ScheduleDetailsFormState {
return {
amountDollars: "",
startsOn: today,
necessity: "NECESSARY",
notes: "",
tags: [],
entryType: "SPENDING",
frequency: "MONTHLY",
intervalCount: 1,
endCondition: "NEVER",
endCount: "",
endDate: "",
nextRunOn: today,
isActive: true
};
}
export default function useEntriesPanelCrud({
filteredEntries,
schedules,
createEntry,
updateEntry,
deleteEntry,
createSchedule,
updateSchedule,
deleteSchedule,
notify,
notifyEntryMutation,
canManageTags
}: UseEntriesPanelCrudParams) {
const router = useRouter();
const today = getTodayIsoDate();
const [newEntryOpen, setNewEntryOpen] = useState(false);
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>("ENTRY");
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
const [entryForm, setEntryForm] = useState<EntryFormState>(() => createInitialEntryForm(today));
const [entryDetailsForm, setEntryDetailsForm] = useState<EntryDetailsFormState>(() => createInitialEntryForm(today));
const [entryDetailsOriginal, setEntryDetailsOriginal] = useState<EntryDetailsFormState | null>(null);
const [scheduleForm, setScheduleForm] = useState<ScheduleFormState>(() => createInitialScheduleForm(today));
const [scheduleDetailsForm, setScheduleDetailsForm] = useState<ScheduleDetailsFormState>(() => createInitialScheduleDetailsForm(today));
const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState<ScheduleDetailsFormState | null>(null);
const amountInputRef = useRef<HTMLInputElement>(null);
const tagsInputRef = useRef<HTMLInputElement>(null);
function handleEmptyTagAction() {
if (!canManageTags) return;
router.push("/groups/settings");
}
function hasEntryChanges() {
if (!entryDetailsOriginal) return false;
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
return (
entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars ||
entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt ||
entryDetailsForm.necessity !== entryDetailsOriginal.necessity ||
entryDetailsForm.notes !== entryDetailsOriginal.notes ||
entryDetailsForm.entryType !== entryDetailsOriginal.entryType ||
normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags)
);
}
function hasScheduleChanges() {
if (!scheduleDetailsOriginal) return false;
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
return (
scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars ||
scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn ||
scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity ||
scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes ||
scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType ||
scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency ||
scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount ||
scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition ||
scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount ||
scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate ||
scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn ||
scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive ||
normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags)
);
}
async function submitNewEntry(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!event.currentTarget.reportValidity()) return;
const amountDollars = Number(entryForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !entryForm.tags.length) return;
const created = await createEntry({
entryType: entryForm.entryType,
amountDollars,
occurredAt: entryForm.occurredAt,
necessity: entryForm.necessity,
purchaseType: entryForm.tags.join(", ") || "General",
notes: entryForm.notes.trim() || undefined,
tags: entryForm.tags
});
if (!created) return;
setNewEntryOpen(false);
setEntryForm(createInitialEntryForm(today));
notify({ title: "Entry added", message: `${created.tags.join(", ")} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
notifyEntryMutation();
}
async function submitNewSchedule(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const amountDollars = Number(scheduleForm.amountDollars || 0);
if (!Number.isFinite(amountDollars) || amountDollars <= 0 || !scheduleForm.tags.length) return;
const created = await createSchedule({
entryType: scheduleForm.entryType,
amountDollars,
startsOn: scheduleForm.startsOn,
necessity: scheduleForm.necessity,
purchaseType: scheduleForm.tags.join(", ") || "General",
notes: scheduleForm.notes.trim() || undefined,
tags: scheduleForm.tags,
frequency: scheduleForm.frequency,
intervalCount: scheduleForm.intervalCount,
endCondition: scheduleForm.endCondition,
endCount: scheduleForm.endCondition === "AFTER_COUNT" ? Number(scheduleForm.endCount || 0) || null : null,
endDate: scheduleForm.endCondition === "BY_DATE" ? scheduleForm.endDate || null : null,
createEntryNow: scheduleForm.createEntryNow
});
if (!created) return;
setNewScheduleOpen(false);
setScheduleForm(createInitialScheduleForm(today));
notify({ title: "Schedule added", message: `${created.tags.join(", ") || "Schedule"} - $${created.amountDollars.toFixed(2)}`, tone: "success" });
if (scheduleForm.createEntryNow) notifyEntryMutation();
}
function openEntryDetails(id: number) {
const index = filteredEntries.findIndex(entry => Number(entry.id) === Number(id));
if (index < 0) return;
const entry = filteredEntries[index];
const form: EntryDetailsFormState = {
amountDollars: String(entry.amountDollars),
occurredAt: entry.occurredAt,
necessity: entry.necessity,
notes: entry.notes || "",
tags: entry.tags || [],
entryType: entry.entryType
};
setSelectedEntryId(Number(id));
setSelectedEntryIndex(index);
setEntryDetailsForm(form);
setEntryDetailsOriginal(form);
setEntryRemovedTags([]);
setEntryDetailsOpen(true);
}
function openScheduleDetails(id: number) {
const schedule = schedules.find(item => Number(item.id) === Number(id));
if (!schedule) return;
const form: ScheduleDetailsFormState = {
amountDollars: String(schedule.amountDollars),
startsOn: schedule.startsOn,
necessity: schedule.necessity,
notes: schedule.notes || "",
tags: schedule.tags || [],
entryType: schedule.entryType,
frequency: schedule.frequency,
intervalCount: schedule.intervalCount,
endCondition: schedule.endCondition,
endCount: schedule.endCount == null ? "" : String(schedule.endCount),
endDate: schedule.endDate || "",
nextRunOn: schedule.nextRunOn,
isActive: schedule.isActive
};
setSelectedScheduleId(Number(id));
setScheduleDetailsForm(form);
setScheduleDetailsOriginal(form);
setScheduleRemovedTags([]);
setScheduleDetailsOpen(true);
}
async function submitEntryUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedEntryId || !hasEntryChanges()) return;
const amount = Number(entryDetailsForm.amountDollars || 0);
const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateEntry({
id: selectedEntryId,
entryType: entryDetailsForm.entryType,
amountDollars: amount,
occurredAt: entryDetailsForm.occurredAt,
necessity: entryDetailsForm.necessity,
purchaseType: tags.join(", ") || "General",
notes: entryDetailsForm.notes.trim() || undefined,
tags
});
if (!updated) return;
setEntryDetailsOpen(false);
notify({ title: "Entry updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
notifyEntryMutation();
}
async function submitScheduleUpdate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedScheduleId || !hasScheduleChanges()) return;
const amount = Number(scheduleDetailsForm.amountDollars || 0);
const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag));
if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return;
const updated = await updateSchedule({
id: selectedScheduleId,
entryType: scheduleDetailsForm.entryType,
amountDollars: amount,
startsOn: scheduleDetailsForm.startsOn,
necessity: scheduleDetailsForm.necessity,
purchaseType: tags.join(", ") || "General",
notes: scheduleDetailsForm.notes.trim() || undefined,
tags,
frequency: scheduleDetailsForm.frequency,
intervalCount: scheduleDetailsForm.intervalCount,
endCondition: scheduleDetailsForm.endCondition,
endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null,
endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null,
nextRunOn: scheduleDetailsForm.nextRunOn,
isActive: scheduleDetailsForm.isActive
});
if (!updated) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` });
}
async function confirmDelete() {
if (deleteTarget === "ENTRY" && selectedEntryId) {
const removed = await deleteEntry(selectedEntryId);
if (!removed) return;
setEntryDetailsOpen(false);
notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" });
notifyEntryMutation();
}
if (deleteTarget === "SCHEDULE" && selectedScheduleId) {
const removed = await deleteSchedule(selectedScheduleId);
if (!removed) return;
setScheduleDetailsOpen(false);
notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" });
}
}
function prevEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === 0 ? filteredEntries.length - 1 : current - 1;
openEntryDetails(filteredEntries[index].id);
}
function nextEntry() {
if (!filteredEntries.length) return;
const current = selectedEntryIndex ?? 0;
const index = current === filteredEntries.length - 1 ? 0 : current + 1;
openEntryDetails(filteredEntries[index].id);
}
return {
newEntryOpen,
setNewEntryOpen,
newScheduleOpen,
setNewScheduleOpen,
entryDetailsOpen,
setEntryDetailsOpen,
scheduleDetailsOpen,
setScheduleDetailsOpen,
confirmDeleteOpen,
setConfirmDeleteOpen,
deleteTarget,
setDeleteTarget,
selectedEntryIndex,
selectedScheduleId,
entryRemovedTags,
setEntryRemovedTags,
scheduleRemovedTags,
setScheduleRemovedTags,
entryForm,
setEntryForm,
entryDetailsForm,
setEntryDetailsForm,
entryDetailsOriginal,
scheduleForm,
setScheduleForm,
scheduleDetailsForm,
setScheduleDetailsForm,
scheduleDetailsOriginal,
amountInputRef,
tagsInputRef,
handleEmptyTagAction,
hasEntryChanges,
hasScheduleChanges,
submitNewEntry,
submitNewSchedule,
openEntryDetails,
openScheduleDetails,
submitEntryUpdate,
submitScheduleUpdate,
confirmDelete,
prevEntry,
nextEntry
};
}
export type EntriesPanelCrudState = ReturnType<typeof useEntriesPanelCrud>;

View File

@ -0,0 +1,145 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { Entry, Schedule } from "@/lib/shared/types";
import type { EntryTab, EntriesPanelFilters } from "@/features/entries/components/entries-panel.types";
import { createEmptyEntriesFilters } from "@/features/entries/components/entries-panel.utils";
import useInfiniteVisibleCount from "@/features/entries/components/use-infinite-visible-count";
type UseEntriesPanelFiltersParams = {
entries: Entry[];
schedules: Schedule[];
pageSize: number;
};
export default function useEntriesPanelFilters({ entries, schedules, pageSize }: UseEntriesPanelFiltersParams) {
const [entryTab, setEntryTab] = useState<EntryTab>("ENTRIES");
const [filters, setFilters] = useState<EntriesPanelFilters>(() => createEmptyEntriesFilters());
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
const [filterOpen, setFilterOpen] = useState(false);
const entriesLoadSentinelRef = useRef<HTMLDivElement>(null);
const schedulesLoadSentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}, [pageSize]);
const activeFilterCount = useMemo(() => {
let count = 0;
if (filters.amountMin) count += 1;
if (filters.amountMax) count += 1;
if (filters.dateFrom) count += 1;
if (filters.dateTo) count += 1;
if (filters.necessity !== "ANY") count += 1;
if (filters.notesQuery.trim()) count += 1;
if (filters.tags.length) count += 1;
return count;
}, [filters]);
const filteredEntries = useMemo(() => {
const min = filters.amountMin ? Number(filters.amountMin) : null;
const max = filters.amountMax ? Number(filters.amountMax) : null;
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
const query = filters.notesQuery.trim().toLowerCase();
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
return entries.filter(entry => {
if (min != null && entry.amountDollars < min) return false;
if (max != null && entry.amountDollars > max) return false;
const time = new Date(entry.occurredAt).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false;
if (query && !(entry.notes || "").toLowerCase().includes(query)) return false;
if (!tagsFilter.length) return true;
const entryTags = (entry.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") return tagsFilter.every(tag => entryTags.includes(tag));
return tagsFilter.some(tag => entryTags.includes(tag));
});
}, [entries, filters]);
const filteredSchedules = useMemo(() => {
const min = filters.amountMin ? Number(filters.amountMin) : null;
const max = filters.amountMax ? Number(filters.amountMax) : null;
const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null;
const query = filters.notesQuery.trim().toLowerCase();
const tagsFilter = filters.tags.map(tag => tag.toLowerCase());
return schedules.filter(schedule => {
if (min != null && schedule.amountDollars < min) return false;
if (max != null && schedule.amountDollars > max) return false;
const time = new Date(schedule.startsOn).getTime();
if (from != null && !Number.isNaN(from) && time < from) return false;
if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false;
if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false;
if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false;
if (!tagsFilter.length) return true;
const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase());
if (filters.tagsMode === "ALL") return tagsFilter.every(tag => scheduleTags.includes(tag));
return tagsFilter.some(tag => scheduleTags.includes(tag));
});
}, [filters, schedules]);
const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]);
const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]);
const hasMoreEntries = filteredEntries.length > visibleEntries.length;
const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length;
useInfiniteVisibleCount({
enabled: entryTab === "ENTRIES",
hasMore: hasMoreEntries,
totalCount: filteredEntries.length,
pageSize,
sentinelRef: entriesLoadSentinelRef,
setVisibleCount: setEntryVisibleCount
});
useInfiniteVisibleCount({
enabled: entryTab === "SCHEDULES",
hasMore: hasMoreSchedules,
totalCount: filteredSchedules.length,
pageSize,
sentinelRef: schedulesLoadSentinelRef,
setVisibleCount: setScheduleVisibleCount
});
function clearFilters() {
setFilters(createEmptyEntriesFilters());
setEntryVisibleCount(pageSize);
setScheduleVisibleCount(pageSize);
}
function onFilterAddTag(tag: string) {
setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }));
}
function onFilterToggleTag(tag: string) {
setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }));
}
return {
entryTab,
setEntryTab,
filters,
setFilters,
filterOpen,
setFilterOpen,
activeFilterCount,
filteredEntries,
filteredSchedules,
visibleEntries,
visibleSchedules,
hasMoreEntries,
hasMoreSchedules,
entriesLoadSentinelRef,
schedulesLoadSentinelRef,
clearFilters,
onFilterAddTag,
onFilterToggleTag
};
}
export type EntriesPanelFiltersState = ReturnType<typeof useEntriesPanelFilters>;

View File

@ -0,0 +1,97 @@
"use client";
import { useEffect } from "react";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { isEditableTarget } from "@/features/entries/components/entries-panel.utils";
type UseInfiniteVisibleCountParams = {
enabled: boolean;
hasMore: boolean;
totalCount: number;
pageSize: number;
sentinelRef: MutableRefObject<HTMLDivElement | null>;
setVisibleCount: Dispatch<SetStateAction<number>>;
};
export default function useInfiniteVisibleCount({
enabled,
hasMore,
totalCount,
pageSize,
sentinelRef,
setVisibleCount
}: UseInfiniteVisibleCountParams) {
useEffect(() => {
if (!enabled || !hasMore) return;
let touchY: number | null = null;
let lastLoadAt = 0;
let lastScrollY = window.scrollY;
function shouldLoadMore() {
const sentinel = sentinelRef.current;
if (!sentinel) return false;
const rect = sentinel.getBoundingClientRect();
return rect.top <= window.innerHeight + 48;
}
function tryLoadMore() {
if (!shouldLoadMore()) return;
const now = Date.now();
if (now - lastLoadAt < 150) return;
lastLoadAt = now;
setVisibleCount(prev => {
if (prev >= totalCount) return prev;
return Math.min(prev + pageSize, totalCount);
});
}
function onWheel(event: WheelEvent) {
if (event.deltaY <= 0) return;
tryLoadMore();
}
function onScroll() {
const nextY = window.scrollY;
if (nextY <= lastScrollY) {
lastScrollY = nextY;
return;
}
lastScrollY = nextY;
tryLoadMore();
}
function onKeyDown(event: KeyboardEvent) {
if (isEditableTarget(event.target)) return;
if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) {
tryLoadMore();
}
}
function onTouchStart(event: TouchEvent) {
touchY = event.touches[0]?.clientY ?? null;
}
function onTouchMove(event: TouchEvent) {
const nextY = event.touches[0]?.clientY;
if (touchY == null || nextY == null) return;
const delta = touchY - nextY;
touchY = nextY;
if (delta > 10) tryLoadMore();
}
window.addEventListener("wheel", onWheel, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("keydown", onKeyDown);
window.addEventListener("touchstart", onTouchStart, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
return () => {
window.removeEventListener("wheel", onWheel);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("touchstart", onTouchStart);
window.removeEventListener("touchmove", onTouchMove);
};
}, [enabled, hasMore, pageSize, sentinelRef, setVisibleCount, totalCount]);
}

View File

@ -0,0 +1,139 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
type GroupSettingsAuditCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsAuditCard({ vm }: GroupSettingsAuditCardProps) {
if (!vm.canViewAudit) return null;
return (
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Audit log</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => vm.setAuditOpen(prev => !prev)}
>
{vm.auditOpen ? "Collapse" : "Expand"}
</button>
</div>
{vm.auditOpen ? (
<>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
<div className="text-[11px] text-soft">Total logs</div>
<div className="text-base font-semibold">{vm.events.length}</div>
</div>
<div className="rounded-lg border border-accent-weak bg-panel px-3 py-2">
<div className="text-[11px] text-soft">Logs today</div>
<div className="text-base font-semibold">{vm.logsToday}</div>
</div>
<button
type="button"
className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-left disabled:opacity-60"
onClick={() => vm.mostActiveUser ? vm.setAuditQuery(vm.mostActiveUser.searchValue) : null}
disabled={!vm.mostActiveUser}
>
<div className="text-[11px] text-soft">Most Active User ({vm.mostActiveCount})</div>
<div className="text-base font-semibold">
{vm.mostActiveUser ? `${vm.mostActiveUser.name}` : "-"}
</div>
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
{([
{ key: "entries", label: "Entries" },
{ key: "members", label: "Members" },
{ key: "tags", label: "Tags" },
{ key: "settings", label: "Settings" }
] as const).map(filter => (
<button
key={filter.key}
type="button"
className={`rounded-lg border px-2 py-1 text-xs font-semibold ${vm.auditFilters.includes(filter.key) ? "border-accent bg-accent-soft" : "border-accent-weak bg-panel"}`}
onClick={() => vm.setAuditFilters(prev => prev.includes(filter.key) ? prev.filter(item => item !== filter.key) : [...prev, filter.key])}
>
{filter.label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[220px] flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
className="input-base w-full px-9 py-2 text-sm"
placeholder="Search by user, action, or request id"
value={vm.auditQuery}
onChange={event => vm.setAuditQuery(event.target.value)}
/>
</div>
<div className="flex min-w-[220px] flex-1 items-center gap-2 flex-nowrap">
<div className="relative flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<input
type="date"
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
value={vm.auditFrom}
onChange={event => vm.setAuditFrom(event.target.value)}
/>
</div>
<div className="relative flex-1">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-soft" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<input
type="date"
className="no-date-icon input-base date-input w-full pl-9 pr-3 py-2 text-sm"
value={vm.auditTo}
onChange={event => vm.setAuditTo(event.target.value)}
/>
</div>
</div>
</div>
{!vm.filteredEvents.length ? (
<div className="text-xs text-soft">No audit events match your filters.</div>
) : (
<div className="max-h-72 space-y-2 overflow-auto rounded-lg border border-accent-weak bg-panel p-2">
{vm.filteredEvents.slice(0, vm.auditLimit).map(event => (
<div key={event.id} className="rounded-lg border border-accent-weak bg-panel px-3 py-2 text-xs text-soft">
<div className="text-sm font-semibold text-[color:var(--color-text)]">{event.eventType}</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-soft">
<span>Actor: {vm.getMemberLabel(event.actorUserId)}</span>
<span>Role: {event.actorRole ?? "-"}</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
</div>
</div>
))}
{vm.filteredEvents.length > vm.auditLimit ? (
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => vm.setAuditLimit(prev => prev + 10)}
>
Load more
</button>
) : null}
</div>
)}
</>
) : (
<div className="text-xs text-soft">Audit log is collapsed.</div>
)}
</div>
);
}

View File

@ -0,0 +1,49 @@
"use client";
import GroupSettingsAuditCard from "@/features/groups/components/group-settings-audit-card";
import GroupSettingsDangerCard from "@/features/groups/components/group-settings-danger-card";
import GroupSettingsGeneralCard from "@/features/groups/components/group-settings-general-card";
import GroupSettingsJoinInvitesCard from "@/features/groups/components/group-settings-join-invites-card";
import GroupSettingsMembersCard from "@/features/groups/components/group-settings-members-card";
import GroupSettingsModals from "@/features/groups/components/group-settings-modals";
import useGroupSettingsViewModel from "@/features/groups/components/use-group-settings-view-model";
export default function GroupSettingsContent({ groupId }: { groupId: number }) {
const vm = useGroupSettingsViewModel(groupId);
if (!vm.group) {
return (
<div className="panel panel-accent p-4">
<div className="text-sm text-muted">Loading group settings...</div>
</div>
);
}
return (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">{vm.group.name} settings</h1>
<p className="text-sm text-muted">Manage tags and permissions for this group.</p>
</div>
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-1.5 text-sm"
onClick={vm.goBack}
>
Back
</button>
</div>
<GroupSettingsGeneralCard vm={vm} />
<GroupSettingsJoinInvitesCard vm={vm} />
<GroupSettingsMembersCard vm={vm} />
<GroupSettingsDangerCard vm={vm} />
<GroupSettingsAuditCard vm={vm} />
</div>
<GroupSettingsModals vm={vm} />
</>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
type GroupSettingsDangerCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsDangerCard({ vm }: GroupSettingsDangerCardProps) {
return (
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title text-red-200">Danger zone</div>
</div>
<div className="space-y-2">
{vm.showLeaveGroup ? (
<button
type="button"
className="w-full rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={() => vm.setConfirmLeaveOpen(true)}
>
Leave group
</button>
) : null}
<button
type="button"
className="w-full rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
onClick={() => vm.setConfirmDeleteGroupOpen(true)}
disabled={!vm.isOwner}
>
Delete group
</button>
</div>
{!vm.isOwner ? <div className="text-xs text-soft">Only the owner can delete this group.</div> : null}
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
type GroupSettingsGeneralCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsGeneralCard({ vm }: GroupSettingsGeneralCardProps) {
if (!vm.group) return null;
return (
<div className="panel panel-accent p-4 space-y-4">
<div className="card-header">
<div className="card-title">General Settings</div>
</div>
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<div className="text-xs text-soft">Group name</div>
<div className="text-base font-semibold">{vm.group.name}</div>
</div>
{vm.isAdmin ? (
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={vm.handleOpenRenameModal}
>
Change
</button>
) : null}
</div>
{!vm.isAdmin ? (
<div className="text-xs text-soft">Only admins can rename the group.</div>
) : null}
<div className="divider" />
{vm.isAdmin ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Allow members to manage tags</div>
<button
type="button"
aria-pressed={vm.localAllowMemberTagManage}
className={`relative h-7 w-12 rounded-full border transition ${vm.localAllowMemberTagManage ? "border-emerald-400 bg-emerald-500/20" : "border-red-400/60 bg-red-500/10"}`}
onClick={() => vm.handleToggleAllowMembers(!vm.localAllowMemberTagManage)}
>
<span
className={`absolute left-0.5 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full transition ${vm.localAllowMemberTagManage ? "translate-x-[22px] bg-emerald-200" : "translate-x-0 bg-red-200"}`}
/>
</button>
</div>
<div className="divider" />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold">Group Tags ({vm.tags.length})</div>
<div className="flex items-center gap-2">
{vm.showAllTags ? (
<button
type="button"
className="rounded-lg btn-outline-accent px-2 py-1 text-xs"
onClick={() => vm.setShowAllTags(false)}
>
Show less
</button>
) : null}
{vm.canManageTags ? (
<button
type="button"
className="rounded-lg btn-accent px-2 py-1 text-xs font-semibold"
onClick={() => vm.setTagModalOpen(true)}
>
Edit tags
</button>
) : null}
</div>
</div>
<div className={vm.tagsScrollable ? "max-h-60 overflow-auto resize-y pr-1" : ""}>
<div className="flex flex-wrap gap-2">
{vm.visibleTags.map(tag => (
<span key={tag} className="rounded-full border border-accent-weak bg-accent-soft px-2 py-0.5 text-xs text-[color:var(--color-text)]">
#{tag}
</span>
))}
{!vm.showAllTags && vm.hasMoreTags ? (
<button
type="button"
className="rounded-full border border-accent-weak bg-panel px-2 py-0.5 text-xs text-soft"
onClick={() => vm.setShowAllTags(true)}
aria-label="Show all tags"
>
more . . .
</button>
) : null}
{!vm.tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,174 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
type GroupSettingsJoinInvitesCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsJoinInvitesCard({ vm }: GroupSettingsJoinInvitesCardProps) {
if (!vm.isAdmin) return null;
return (
<div className="panel panel-accent p-4 space-y-4">
<div className="card-header">
<div className="card-title">Join and Invites</div>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm font-semibold">Join policy</div>
<ToggleButtonGroup
value={vm.localJoinPolicy}
onChange={vm.handleJoinPolicyChange}
ariaLabel="Join policy"
className="flex flex-wrap gap-2"
buttonBaseClassName="rounded-lg border"
sizeClassName="px-3 py-1.5 text-xs font-semibold transition"
activeClassName="border-accent bg-accent-soft"
inactiveClassName="border-accent-weak bg-panel hover:border-accent"
options={[
{ value: "NOT_ACCEPTING", label: "Disabled" },
{ value: "AUTO_ACCEPT", label: "Auto" },
{ value: "APPROVAL_REQUIRED", label: "Manual" }
]}
/>
</div>
</div>
<div className="divider" />
<div className="space-y-2">
<div className="text-sm font-semibold">Join Requests ({vm.requests.length})</div>
{!vm.requests.length ? (
<div className="text-xs text-soft">No pending requests.</div>
) : (
<div className="space-y-2">
{vm.requests.map(request => (
<div key={request.id} className="flex items-center justify-between rounded-lg border border-accent-weak bg-panel px-3 py-2 text-sm">
<div>
<div className="font-medium">{request.displayName || request.email}</div>
<div className="text-xs text-soft">{request.email}</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg btn-outline-accent px-3 py-1.5 text-xs"
onClick={() => vm.approve(request.userId, request.id)}
>
Approve
</button>
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-1.5 text-xs text-red-200"
onClick={() => vm.deny(request.userId)}
>
Deny
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="divider" />
<div className="space-y-3">
<div className="text-sm font-semibold">Invite links</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="input-base px-2 py-1.5 text-xs"
value={vm.inviteTtlDays}
onChange={event => vm.setInviteTtlDays(Number(event.target.value))}
>
{[1, 2, 3, 4, 5, 6, 7].map(days => (
<option key={days} value={days}>{days} day{days === 1 ? "" : "s"}</option>
))}
</select>
<select
className="input-base px-2 py-1.5 text-xs"
value={vm.inviteMaxUses}
onChange={event => vm.setInviteMaxUses(event.target.value as "UNLIMITED" | "SINGLE")}
>
<option value="UNLIMITED">Unlimited</option>
<option value="SINGLE">1 use</option>
</select>
<button
type="button"
className="rounded-lg btn-accent px-3 py-1.5 text-xs font-semibold"
onClick={() => vm.createInvite({ policy: vm.localJoinPolicy, singleUse: vm.inviteMaxUses === "SINGLE", ttlDays: vm.inviteTtlDays })}
disabled={vm.localJoinPolicy === "NOT_ACCEPTING"}
>
Create link
</button>
</div>
{!vm.links.length ? (
<div className="text-xs text-soft">No invite links yet.</div>
) : (
<div className="space-y-2">
{vm.links.map(link => (
<div
key={link.id}
className={`rounded-lg border px-3 py-2 text-sm ${link.revokedAt || vm.isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt) ? "border-red-400/60 bg-red-500/5" : "border-accent-weak bg-panel"}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-soft">Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}</div>
{(() => {
const showRevive = link.revokedAt || vm.isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt);
const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !vm.isInviteExpired(link.expiresAt);
const options = [
{
value: "COPY",
label: "Copy link",
className: "btn-outline-accent",
disabled: vm.localJoinPolicy === "NOT_ACCEPTING",
onClick: () => vm.handleCopyInvite(link.token)
},
...(showRevive
? [{
value: "REVIVE",
label: "Revive",
className: "btn-outline-accent",
disabled: vm.localJoinPolicy === "NOT_ACCEPTING",
onClick: () => vm.reviveInvite(link.id, vm.inviteTtlDays)
}]
: showRevoke
? [{
value: "REVOKE",
label: "Revoke",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => vm.revokeInvite(link.id)
}]
: []),
{
value: "DELETE",
label: "Delete",
className: "border border-red-400/60 bg-red-500/10 text-red-200",
onClick: () => vm.setConfirmDeleteInvite({ id: link.id, token: link.token })
}
];
return (
<ToggleButtonGroup
value={null}
ariaLabel="Invite actions"
className="flex items-center gap-2"
buttonBaseClassName="rounded-lg"
sizeClassName="px-2 py-1 text-xs"
activeClassName=""
inactiveClassName=""
options={options}
/>
);
})()}
</div>
<div className="mt-2 flex flex-wrap gap-3 text-[11px] text-soft">
<span>Expires {vm.formatInviteExpiry(link.expiresAt)}</span>
<span>Uses: {link.singleUse ? "1 use" : "Unlimited"}</span>
<span>Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : vm.isInviteExpired(link.expiresAt) ? "Expired" : "Active"}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,77 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
type GroupSettingsMembersCardProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsMembersCard({ vm }: GroupSettingsMembersCardProps) {
return (
<div className="panel panel-accent p-4 space-y-3">
<div className="card-header">
<div className="card-title">Members</div>
<div className="text-xs text-soft">{vm.memberCount} total</div>
</div>
<div className="space-y-2">
{vm.members.map(member => {
const isSelf = member.userId === vm.currentUserId;
const privilegeLabel = member.role === "GROUP_OWNER"
? "Owner - Full control"
: member.role === "GROUP_ADMIN"
? "Admin - Manage members"
: "Member - Entries only";
return (
<div
key={member.userId}
className={`flex flex-wrap items-center justify-between gap-3 rounded-lg border border-accent-weak px-3 py-2 text-sm ${isSelf ? "bg-accent-soft" : "bg-panel"}`}
>
<div>
<div className={`font-medium ${isSelf ? "text-[color:var(--color-text)]" : ""}`}>
{member.displayName || member.email}
{isSelf ? <span className="ml-2 rounded-full border border-accent-weak px-2 py-0.5 text-[10px] text-soft">You</span> : null}
</div>
<div className="text-xs text-soft">{member.email}</div>
<div className="mt-1 text-[11px] text-soft">{privilegeLabel}</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{vm.isAdmin ? (
<select
className="input-base px-2 py-1 text-xs"
value={member.role}
onChange={event => vm.handleRoleChange(member.userId, event.target.value as "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER")}
disabled={member.role === "GROUP_OWNER"}
>
<option value="MEMBER">Member</option>
<option value="GROUP_ADMIN">Admin</option>
{vm.isOwner ? <option value="GROUP_OWNER">Owner</option> : null}
</select>
) : (
<span className="text-xs text-soft">{member.role}</span>
)}
{vm.isAdmin && member.role !== "GROUP_OWNER" ? (
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-2 py-1 text-xs text-red-200"
onClick={() => vm.setConfirmKick({ userId: member.userId, name: member.displayName || member.email })}
>
Remove
</button>
) : null}
{vm.isOwner && member.role !== "GROUP_OWNER" ? (
<button
type="button"
className="rounded-lg btn-accent px-2 py-1 text-xs"
onClick={() => vm.setConfirmTransfer({ userId: member.userId, name: member.displayName || member.email })}
>
Make owner
</button>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,213 @@
"use client";
import type { GroupSettingsViewModelState } from "@/features/groups/components/use-group-settings-view-model";
import TagInput from "@/shared/components/forms/tag-input";
import ConfirmRetypeModal from "@/shared/components/modals/confirm-retype-modal";
import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal";
type GroupSettingsModalsProps = {
vm: GroupSettingsViewModelState;
};
export default function GroupSettingsModals({ vm }: GroupSettingsModalsProps) {
return (
<>
{vm.renameModalOpen ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={vm.handleCloseRenameModal}>
<div
className="relative w-full max-w-sm rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") vm.handleCloseRenameModal();
if (event.key === "Enter" && vm.renameDirty && vm.isAdmin && vm.renameValue.trim()) vm.setConfirmRenameOpen(true);
}}
role="dialog"
tabIndex={-1}
>
<button
type="button"
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
onClick={vm.handleCloseRenameModal}
aria-label="Close"
>
x
</button>
<div className="text-lg font-semibold">Change group name</div>
<input
className={`mt-4 w-full input-base px-3 py-2 text-sm ${vm.renameValue.trim() ? "" : "border-red-400/70"}`}
value={vm.renameValue}
onChange={event => vm.setRenameValue(event.target.value)}
placeholder="Group name"
/>
{vm.renameDirty ? (
<div className="mt-3 rounded-lg border border-yellow-400/60 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-200">
You have unsaved changes.
</div>
) : null}
<div className="mt-4 flex items-center gap-2">
{vm.renameDirty ? (
<>
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={vm.handleCloseRenameModal}
>
Discard changes
</button>
<button
type="button"
className="flex-1 rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={() => vm.setConfirmRenameOpen(true)}
disabled={!vm.isAdmin || !vm.renameValue.trim()}
>
Rename
</button>
</>
) : (
<button
type="button"
className="flex-1 rounded-lg btn-outline-accent px-3 py-2 text-sm font-semibold"
onClick={vm.handleCloseRenameModal}
>
Dismiss
</button>
)}
</div>
</div>
</div>
) : null}
{vm.tagModalOpen ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4 !mt-0" onClick={() => vm.setTagModalOpen(false)}>
<div
className="w-full max-w-xl rounded-2xl border border-accent-weak bg-panel p-5"
onClick={event => event.stopPropagation()}
onKeyDown={event => {
if (event.key === "Escape") vm.setTagModalOpen(false);
}}
role="dialog"
tabIndex={-1}
>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Edit tags</div>
<button type="button" className="rounded-lg btn-outline-accent px-2 py-1 text-sm" onClick={() => vm.setTagModalOpen(false)} aria-label="Close">x</button>
</div>
<div className="mt-4 space-y-3">
<TagInput
label="Add tags"
tags={vm.pendingTags}
suggestions={vm.tags}
enableBackspaceRemove
onToggleTag={tag => vm.setPendingTags(prev => prev.filter(item => item !== tag))}
onAddTag={tag => vm.setPendingTags(prev => (prev.includes(tag) ? prev : [...prev, tag]))}
/>
<button
type="button"
className="rounded-lg btn-accent px-3 py-2 text-sm font-semibold disabled:opacity-60"
onClick={vm.handleSaveTags}
disabled={!vm.pendingTags.length || !vm.canManageTags}
>
Save tags
</button>
{!vm.canManageTags ? (
<div className="text-xs text-soft">Only admins can add new tags.</div>
) : null}
<div className="divider" />
<div className="text-sm font-semibold">Existing tags</div>
<div className="max-h-60 overflow-auto resize-y pr-1">
<div className="flex flex-wrap gap-2">
{vm.tags.map(tag => (
<button
key={tag}
type="button"
className={`rounded-full border px-2 py-0.5 text-xs text-[color:var(--color-text)] ${vm.toggleRemoveTags.includes(tag) ? "border-red-400/60 text-red-200 bg-red-500/10" : "border-accent-weak bg-accent-soft hover:border-accent"}`}
onClick={() => {
if (!vm.canManageTags) return;
vm.setToggleRemoveTags(prev => (prev.includes(tag) ? prev.filter(item => item !== tag) : [...prev, tag]));
}}
>
#{tag}
</button>
))}
{!vm.tags.length ? <div className="text-xs text-soft">No tags yet.</div> : null}
</div>
</div>
{vm.toggleRemoveTags.length ? (
<button
type="button"
className="rounded-lg border border-red-400/60 bg-red-500/10 px-3 py-2 text-sm font-semibold text-red-200"
onClick={() => vm.setConfirmDeleteOpen(true)}
>
Delete selected ({vm.toggleRemoveTags.length})
</button>
) : null}
</div>
</div>
</div>
) : null}
<ConfirmSlideModal
isOpen={vm.confirmDeleteOpen}
title="Delete selected tags"
description="Tags will be removed from this group and any entries using them."
confirmLabel="Delete tags"
onClose={() => vm.setConfirmDeleteOpen(false)}
onConfirm={() => {
vm.setConfirmDeleteOpen(false);
vm.handleConfirmDelete();
}}
/>
<ConfirmSlideModal
isOpen={Boolean(vm.confirmDeleteInvite)}
title="Delete invite link"
description={vm.confirmDeleteInvite ? `Delete invite ${vm.confirmDeleteInvite.token.slice(0, 6)}...${vm.confirmDeleteInvite.token.slice(-4)}?` : ""}
confirmLabel="Delete link"
onClose={() => vm.setConfirmDeleteInvite(null)}
onConfirm={vm.handleConfirmDeleteInvite}
/>
<ConfirmSlideModal
isOpen={vm.confirmRenameOpen}
title="Rename group"
description={`Change group name to \"${vm.renameValue.trim()}\"?`}
confirmLabel="Rename"
onClose={() => vm.setConfirmRenameOpen(false)}
onConfirm={vm.handleRenameGroup}
/>
<ConfirmSlideModal
isOpen={vm.confirmLeaveOpen}
title="Leave group"
description="You will lose access to this group."
confirmLabel="Leave"
onClose={() => vm.setConfirmLeaveOpen(false)}
onConfirm={vm.handleConfirmLeaveGroup}
/>
<ConfirmSlideModal
isOpen={Boolean(vm.confirmKick)}
title="Kick member"
description={vm.confirmKick ? `Remove ${vm.confirmKick.name} from this group?` : ""}
confirmLabel="Kick"
onClose={() => vm.setConfirmKick(null)}
onConfirm={vm.handleConfirmKickMember}
/>
<ConfirmSlideModal
isOpen={Boolean(vm.confirmTransfer)}
title="Transfer ownership"
description={vm.confirmTransfer ? `Make ${vm.confirmTransfer.name} the new owner?` : ""}
confirmLabel="Transfer"
onClose={() => vm.setConfirmTransfer(null)}
onConfirm={vm.handleConfirmTransferOwnership}
/>
<ConfirmRetypeModal
isOpen={vm.confirmDeleteGroupOpen}
title="Delete group"
description="Type DELETE to confirm. This cannot be undone."
expectedText="DELETE"
value={vm.deleteConfirmText}
onChange={vm.setDeleteConfirmText}
confirmLabel="Delete"
onClose={() => vm.setConfirmDeleteGroupOpen(false)}
onConfirm={vm.handleDeleteGroup}
/>
</>
);
}

View File

@ -0,0 +1,15 @@
"use client";
export type AuditFilterKey = "members" | "tags" | "settings" | "entries";
export type InviteMaxUses = "UNLIMITED" | "SINGLE";
export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED";
export type ConfirmUserTarget = {
userId: number;
name: string;
};
export type ConfirmInviteDeleteTarget = {
id: number;
token: string;
};

View File

@ -0,0 +1,42 @@
"use client";
import type { AuditFilterKey } from "@/features/groups/components/group-settings.types";
export function formatInviteExpiry(expiresAt: string) {
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return "Unknown";
const diffMs = expiry - Date.now();
const absMs = Math.abs(diffMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const pick = (value: number, unit: string) => `${value} ${unit}${value === 1 ? "" : "s"}`;
let label = "";
if (absMs < hour) {
const value = Math.max(1, Math.round(absMs / minute));
label = pick(value, "minute");
} else if (absMs < day) {
const value = Math.max(1, Math.round(absMs / hour));
label = pick(value, "hour");
} else {
const value = Math.max(1, Math.round(absMs / day));
label = pick(value, "day");
}
return diffMs >= 0 ? `in ${label}` : `${label} ago`;
}
export function isInviteExpired(expiresAt: string) {
const expiry = new Date(expiresAt).getTime();
if (Number.isNaN(expiry)) return false;
return Date.now() > expiry;
}
export function eventCategory(eventType: string): AuditFilterKey {
const upper = eventType.toUpperCase();
if (upper.includes("SPENDING") || upper.includes("ENTRY")) return "entries";
if (upper.includes("TAG")) return "tags";
if (upper.includes("SETTING") || upper.includes("RENAMED")) return "settings";
return "members";
}

View File

@ -0,0 +1,404 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { groupsDelete, groupsRename } from "@/lib/client/groups";
import useGroupAudit from "@/features/groups/hooks/use-group-audit";
import useGroupInvites from "@/features/groups/hooks/use-group-invites";
import useGroupMembers from "@/features/groups/hooks/use-group-members";
import useGroupSettings from "@/features/groups/hooks/use-group-settings";
import useTags from "@/features/tags/hooks/use-tags";
import type {
AuditFilterKey,
ConfirmInviteDeleteTarget,
ConfirmUserTarget,
InviteMaxUses,
JoinPolicy
} from "@/features/groups/components/group-settings.types";
import { eventCategory, formatInviteExpiry, isInviteExpired } from "@/features/groups/components/group-settings.utils";
import { useGroupsContext } from "@/hooks/groups-context";
import { useNotificationsContext } from "@/hooks/notifications-context";
export default function useGroupSettingsViewModel(groupId: number) {
const router = useRouter();
const { groups, activeGroupId, setActiveGroup } = useGroupsContext();
const { notify } = useNotificationsContext();
const group = useMemo(() => groups.find(item => item.id === groupId) || null, [groups, groupId]);
const role = group?.role;
const isAdmin = role === "GROUP_ADMIN" || role === "GROUP_OWNER";
const isOwner = role === "GROUP_OWNER";
const canViewAudit = isAdmin;
const hasSyncedActiveRef = useRef(false);
const lastGroupIdRef = useRef(groupId);
useEffect(() => {
if (lastGroupIdRef.current !== groupId) {
lastGroupIdRef.current = groupId;
hasSyncedActiveRef.current = false;
}
if (!groupId || hasSyncedActiveRef.current) return;
if (activeGroupId !== groupId) setActiveGroup(groupId);
hasSyncedActiveRef.current = true;
}, [activeGroupId, groupId, setActiveGroup]);
const { tags, addTags, removeTag } = useTags(activeGroupId === groupId ? groupId : null);
const { settings, updateSettings } = useGroupSettings(activeGroupId === groupId ? groupId : null);
const { members, requests, currentUserId, approve, deny, promote, demote, kick, transferOwner, leave } = useGroupMembers(activeGroupId === groupId ? groupId : null);
const { links, create: createInvite, revoke: revokeInvite, revive: reviveInvite, remove: deleteInvite } = useGroupInvites(activeGroupId === groupId ? groupId : null);
const { events } = useGroupAudit(activeGroupId === groupId && canViewAudit ? groupId : null);
const [pendingTags, setPendingTags] = useState<string[]>([]);
const [toggleRemoveTags, setToggleRemoveTags] = useState<string[]>([]);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [renameValue, setRenameValue] = useState(group?.name || "");
const [renameModalOpen, setRenameModalOpen] = useState(false);
const [confirmRenameOpen, setConfirmRenameOpen] = useState(false);
const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false);
const [confirmKick, setConfirmKick] = useState<ConfirmUserTarget | null>(null);
const [confirmTransfer, setConfirmTransfer] = useState<ConfirmUserTarget | null>(null);
const [confirmDeleteGroupOpen, setConfirmDeleteGroupOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [tagModalOpen, setTagModalOpen] = useState(false);
const [showAllTags, setShowAllTags] = useState(false);
const [inviteTtlDays, setInviteTtlDays] = useState(7);
const [inviteMaxUses, setInviteMaxUses] = useState<InviteMaxUses>("UNLIMITED");
const [auditOpen, setAuditOpen] = useState(false);
const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<ConfirmInviteDeleteTarget | null>(null);
const [auditFilters, setAuditFilters] = useState<AuditFilterKey[]>(["members", "tags", "settings", "entries"]);
const [auditQuery, setAuditQuery] = useState("");
const [auditFrom, setAuditFrom] = useState("");
const [auditTo, setAuditTo] = useState("");
const [auditLimit, setAuditLimit] = useState(10);
const [localAllowMemberTagManage, setLocalAllowMemberTagManage] = useState(settings.allowMemberTagManage);
const [localJoinPolicy, setLocalJoinPolicy] = useState<JoinPolicy>(settings.joinPolicy);
const canManageTags = role === "GROUP_ADMIN" || role === "GROUP_OWNER" || localAllowMemberTagManage;
const adminCount = members.filter(member => member.role === "GROUP_ADMIN" || member.role === "GROUP_OWNER").length;
const isLastAdmin = isAdmin && adminCount <= 1;
const memberCount = members.length;
const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin;
useEffect(() => {
setRenameValue(group?.name || "");
}, [group?.name]);
useEffect(() => {
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
setLocalJoinPolicy(settings.joinPolicy);
}, [settings.allowMemberTagManage, settings.joinPolicy]);
const handleCloseRenameModal = useCallback(() => {
setRenameModalOpen(false);
setRenameValue(group?.name || "");
}, [group?.name]);
async function handleSaveTags() {
if (!pendingTags.length) return;
const ok = await addTags(pendingTags);
if (ok) {
notify({ title: "Tags updated", message: pendingTags.join(", "), tone: "success" });
setPendingTags([]);
}
}
async function handleConfirmDelete() {
if (!toggleRemoveTags.length) return;
await Promise.all(toggleRemoveTags.map(tag => removeTag(tag)));
notify({ title: "Tags removed", message: toggleRemoveTags.join(", "), tone: "danger" });
setToggleRemoveTags([]);
}
async function handleToggleAllowMembers(value: boolean) {
setLocalAllowMemberTagManage(value);
const ok = await updateSettings(value, localJoinPolicy);
if (ok) {
notify({ title: "Tag permissions updated" });
return;
}
setLocalAllowMemberTagManage(settings.allowMemberTagManage);
notify({ title: "Update failed", message: "Could not save tag permissions.", tone: "danger" });
}
async function handleJoinPolicyChange(nextPolicy: JoinPolicy) {
setLocalJoinPolicy(nextPolicy);
const ok = await updateSettings(localAllowMemberTagManage, nextPolicy);
if (ok) {
notify({ title: "Join policy updated" });
return;
}
setLocalJoinPolicy(settings.joinPolicy);
notify({ title: "Update failed", message: "Could not save join policy.", tone: "danger" });
}
function handleOpenRenameModal() {
setRenameValue(group?.name || "");
setRenameModalOpen(true);
}
async function handleRenameGroup() {
const name = renameValue.trim();
if (!name) return;
const result = await groupsRename({ name });
if ("error" in result) {
notify({ title: "Rename failed", message: result.error.message, tone: "danger" });
return;
}
notify({ title: "Group renamed", message: name });
setConfirmRenameOpen(false);
setRenameModalOpen(false);
}
async function handleRoleChange(targetUserId: number, nextRole: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER") {
if (!isAdmin) return;
const member = members.find(item => item.userId === targetUserId);
if (!member) return;
if (member.role === nextRole) return;
if (nextRole === "GROUP_OWNER") {
if (!isOwner) return;
await transferOwner(targetUserId);
return;
}
if (nextRole === "GROUP_ADMIN") {
await promote(targetUserId);
return;
}
await demote(targetUserId);
}
function getInviteLinkUrl(token: string) {
if (typeof window === "undefined") return token;
return `${window.location.origin}/invite/${token}`;
}
async function handleCopyInvite(token: string) {
try {
await navigator.clipboard.writeText(getInviteLinkUrl(token));
notify({ title: "Invite link copied", message: "Link copied to clipboard" });
} catch {
notify({ title: "Copy failed", tone: "danger" });
}
}
const memberLookup = useMemo(() => {
return new Map(members.map(member => [member.userId, { name: member.displayName || member.email, email: member.email }]));
}, [members]);
function getMemberLabel(userId?: number | null) {
if (!userId) return "System";
const member = memberLookup.get(userId);
return member?.name || `User ${userId}`;
}
const filteredEvents = events.filter(event => {
const category = eventCategory(event.eventType);
if (!auditFilters.includes(category)) return false;
if (auditQuery) {
const query = auditQuery.toLowerCase();
const actor = event.actorUserId ? memberLookup.get(event.actorUserId) : null;
const haystack = `${event.eventType} ${event.actorUserId ?? ""} ${actor?.name || ""} ${actor?.email || ""} ${event.requestId}`.toLowerCase();
if (!haystack.includes(query)) return false;
}
if (auditFrom) {
const from = new Date(auditFrom).getTime();
if (!Number.isNaN(from) && new Date(event.createdAt).getTime() < from) return false;
}
if (auditTo) {
const to = new Date(auditTo).getTime();
if (!Number.isNaN(to) && new Date(event.createdAt).getTime() > to + 24 * 60 * 60 * 1000 - 1) return false;
}
return true;
});
const todayKey = new Date().toISOString().slice(0, 10);
const logsToday = events.filter(event => event.createdAt?.slice(0, 10) === todayKey).length;
const mostActiveUser = (() => {
const counts = new Map<number, number>();
for (const event of events) {
if (!event.actorUserId) continue;
counts.set(event.actorUserId, (counts.get(event.actorUserId) ?? 0) + 1);
}
let top: number | null = null;
let topCount = 0;
counts.forEach((count, userId) => {
if (count > topCount) {
top = userId;
topCount = count;
}
});
if (!top) return null;
const member = memberLookup.get(top);
const name = member?.name || `User ${top}`;
const searchValue = member?.name || member?.email || String(top);
return { userId: top, count: topCount, name, searchValue };
})();
const mostActiveCount = mostActiveUser?.count ?? 0;
const renameDirty = Boolean(group && renameValue.trim() !== group.name);
const visibleTags = showAllTags ? tags : tags.slice(0, 5);
const hasMoreTags = tags.length > 5;
const tagsScrollable = showAllTags && tags.length > 15;
useEffect(() => {
if (!renameModalOpen && !tagModalOpen) return;
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
if (tagModalOpen) setTagModalOpen(false);
if (renameModalOpen) handleCloseRenameModal();
}
if (event.key === "Enter" && !event.shiftKey && !event.defaultPrevented) {
if (renameModalOpen && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true);
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [renameModalOpen, tagModalOpen, renameDirty, isAdmin, renameValue, handleCloseRenameModal]);
async function handleDeleteGroup() {
const result = await groupsDelete();
if ("error" in result) {
notify({ title: "Delete failed", message: result.error.message, tone: "danger" });
return;
}
setConfirmDeleteGroupOpen(false);
setDeleteConfirmText("");
notify({ title: "Group deleted", tone: "danger" });
router.push("/");
}
async function handleConfirmDeleteInvite() {
if (!confirmDeleteInvite) return;
const target = confirmDeleteInvite;
setConfirmDeleteInvite(null);
const ok = await deleteInvite(target.id);
if (ok) notify({ title: "Invite deleted", tone: "danger" });
}
async function handleConfirmLeaveGroup() {
setConfirmLeaveOpen(false);
const ok = await leave();
if (ok) {
notify({ title: "Left group" });
router.push("/");
}
}
async function handleConfirmKickMember() {
if (!confirmKick) return;
const target = confirmKick;
setConfirmKick(null);
const ok = await kick(target.userId);
if (ok) notify({ title: "Member removed", message: target.name, tone: "danger" });
}
async function handleConfirmTransferOwnership() {
if (!confirmTransfer) return;
const target = confirmTransfer;
setConfirmTransfer(null);
const ok = await transferOwner(target.userId);
if (ok) notify({ title: "Ownership transferred", message: target.name });
}
return {
group,
role,
isAdmin,
isOwner,
canViewAudit,
canManageTags,
isLastAdmin,
memberCount,
showLeaveGroup,
tags,
members,
requests,
currentUserId,
links,
events,
filteredEvents,
logsToday,
mostActiveUser,
mostActiveCount,
visibleTags,
hasMoreTags,
tagsScrollable,
pendingTags,
setPendingTags,
toggleRemoveTags,
setToggleRemoveTags,
confirmDeleteOpen,
setConfirmDeleteOpen,
renameValue,
setRenameValue,
renameModalOpen,
setRenameModalOpen,
renameDirty,
confirmRenameOpen,
setConfirmRenameOpen,
confirmLeaveOpen,
setConfirmLeaveOpen,
confirmKick,
setConfirmKick,
confirmTransfer,
setConfirmTransfer,
confirmDeleteGroupOpen,
setConfirmDeleteGroupOpen,
deleteConfirmText,
setDeleteConfirmText,
tagModalOpen,
setTagModalOpen,
showAllTags,
setShowAllTags,
inviteTtlDays,
setInviteTtlDays,
inviteMaxUses,
setInviteMaxUses,
auditOpen,
setAuditOpen,
confirmDeleteInvite,
setConfirmDeleteInvite,
auditFilters,
setAuditFilters,
auditQuery,
setAuditQuery,
auditFrom,
setAuditFrom,
auditTo,
setAuditTo,
auditLimit,
setAuditLimit,
localAllowMemberTagManage,
localJoinPolicy,
handleSaveTags,
handleConfirmDelete,
handleToggleAllowMembers,
handleJoinPolicyChange,
handleOpenRenameModal,
handleCloseRenameModal,
handleRenameGroup,
handleRoleChange,
handleCopyInvite,
formatInviteExpiry,
isInviteExpired,
getMemberLabel,
handleDeleteGroup,
handleConfirmDeleteInvite,
handleConfirmLeaveGroup,
handleConfirmKickMember,
handleConfirmTransferOwnership,
approve,
deny,
promote,
demote,
kick,
transferOwner,
leave,
createInvite,
revokeInvite,
reviveInvite,
deleteInvite,
goBack: () => router.push("/")
};
}
export type GroupSettingsViewModelState = ReturnType<typeof useGroupSettingsViewModel>;

View File

@ -1,7 +1,7 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/components/notifications-toaster";
import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/shared/components/feedback/notifications-toaster";
type NotifyInput = {
title: string;

View File

@ -2,4 +2,9 @@
Cross-domain reusable primitives only.
Current structure:
- `shared/components/forms`: generic form controls (`date-picker`, `tag-input`, `toggle-button-group`)
- `shared/components/modals`: reusable confirmation modals
- `shared/components/feedback`: global feedback UI (`notifications-toaster`)
Use this for generic components/hooks/lib that are not tied to a single business domain.

View File

@ -3,7 +3,6 @@ import type { Config } from "tailwindcss";
export default {
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./features/**/*.{ts,tsx}",
"./shared/**/*.{ts,tsx}"
],

View File

@ -6,5 +6,11 @@ services:
environment:
- NODE_ENV=production
ports:
- "3000:3000"
- "3010:3000"
restart: always
scheduler:
image: git.nicosaya.com/nalalangan/fiddy/scheduler:${IMAGE_TAG}
env_file:
- ./.env
restart: always

View File

@ -152,3 +152,6 @@ Primary outcomes:
### Risks / Notes to Revisit
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
- This Codex session currently cannot write to `.git` (index lock permission denied), so local user-side commits are required for newly staged changes.
- Added first-time operator bootstrap runbook for fresh Ubuntu VM + Docker + Dokploy:
- `docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md`
- Includes command-by-command install, verification checkpoints, hardening baseline, Gitea/Dokploy wiring order, and an execution log template for audit/history.

View File

@ -0,0 +1,326 @@
# Dokploy VM Bootstrap (Verbose Noob Walkthrough)
Purpose: set up a fresh Ubuntu VM (on Proxmox) with Docker and Dokploy for Fiddy deployment, using a copy-paste sequence with verification at each step.
Scope:
- This runbook is for a new Ubuntu VM with SSH enabled.
- It assumes Dokploy will be on its own VM.
- It does not install app containers yet; it prepares the Dokploy control plane.
Important reality:
- This Codex environment cannot directly SSH into your VM or use your credentials.
- You run the commands below on your VM and paste outputs back; I verify and guide next actions.
---
## 0) What to prepare first
Collect these values before you start:
- `VM_IP` = your Ubuntu VM LAN IP (example: `192.168.7.146`)
- `SSH_USER` = ssh username (example: `nico`)
- `SSH_PORT` = usually `22`
- `TZ` = timezone (example: `America/Los_Angeles`)
From your laptop/desktop, connect:
```bash
ssh <SSH_USER>@<VM_IP>
```
If this fails, fix SSH access first.
---
## 1) Baseline OS update and required tools
Run on VM:
```bash
sudo apt update
sudo apt -y upgrade
sudo apt -y install ca-certificates curl gnupg lsb-release ufw fail2ban jq
```
Set timezone:
```bash
sudo timedatectl set-timezone <TZ>
timedatectl
```
Verification:
- `timedatectl` shows your timezone.
- `apt` commands exit without errors.
---
## 2) Install Docker Engine (official repo)
Run on VM:
```bash
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
Enable/start Docker:
```bash
sudo systemctl enable docker
sudo systemctl start docker
sudo systemctl status docker --no-pager
```
Optional (run docker without sudo for your user):
```bash
sudo usermod -aG docker $USER
newgrp docker
docker ps
```
Verification:
- `docker ps` works.
- `docker compose version` returns version.
---
## 3) Host firewall baseline (before Dokploy)
Goal: allow SSH + HTTP/HTTPS, deny others.
Run on VM:
```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 3000/tcp
sudo ufw --force enable
sudo ufw status verbose
```
Notes:
- Dokploy UI is typically on `3000` for first setup.
- After reverse proxy is in place, you can restrict `3000` to LAN/VPN.
---
## 4) Install Dokploy
Run on VM:
```bash
curl -sSL https://dokploy.com/install.sh | sh
```
Check containers:
```bash
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
```
Open Dokploy UI from your browser:
- `http://<VM_IP>:3000`
Create initial admin account.
Verification:
- Dokploy UI loads.
- You can sign in.
---
## 5) Immediate post-install hardening
### 5.1 Keep SSH secure
Edit SSH daemon config:
```bash
sudo nano /etc/ssh/sshd_config
```
Recommended minimum:
- `PermitRootLogin no`
- `PasswordAuthentication no` (only if SSH key login already works)
- `PubkeyAuthentication yes`
Apply:
```bash
sudo systemctl restart ssh
```
### 5.2 Fail2ban basic protection
Create local jail file:
```bash
sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF'
[sshd]
enabled = true
maxretry = 5
findtime = 10m
bantime = 1h
EOF
```
Apply:
```bash
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status
```
---
## 6) Dokploy first configuration (UI)
In Dokploy:
1. Create project: `fiddy`
2. Add registry credentials:
- Host: `git.nicosaya.com`
- Username: same as Gitea registry user
- Password/token: registry token
3. Create app: `fiddy-web`
- Type: Docker image
- Image: `git.nicosaya.com/nalalangan/fiddy/web:main`
- Internal port: `3000`
- Exposed host port: `3010`
- Health path: `/api/health/ready`
4. Create app: `fiddy-scheduler`
- Type: Docker image
- Image: `git.nicosaya.com/nalalangan/fiddy/scheduler:main`
- No public port
5. Enable Auto Deploy for both apps and copy both webhook URLs.
---
## 7) Gitea repo secrets required after Dokploy apps exist
In Gitea repo `Settings -> Secrets -> Actions`, set:
- `REGISTRY_USER`
- `REGISTRY_PASS`
- `DOKPLOY_DEPLOY_HOOK` (from web app in Dokploy)
- `DOKPLOY_SCHEDULER_DEPLOY_HOOK` (from scheduler app in Dokploy)
- `DOKPLOY_HEALTHCHECK_URL`
- final: `https://fiddy.nicosaya.com/api/health/ready`
- temporary allowed: `http://<VM_IP>:3010/api/health/ready`
---
## 8) First deployment flow
From your local repo:
```bash
git commit --allow-empty -m "chore: trigger dokploy first deploy"
git push origin main
```
Expected `.gitea/workflows/deploy-dokploy.yml` behavior:
1. build/push web image
2. build/push scheduler image
3. call Dokploy web hook
4. call Dokploy scheduler hook
5. wait for ready health check
Verification:
- Gitea workflow is green.
- Dokploy app logs show successful pull/start.
- Health URLs respond 200.
---
## 9) Troubleshooting quick map
- Workflow fails on registry login:
- re-check `REGISTRY_USER` and `REGISTRY_PASS`.
- Dokploy hook step fails:
- re-check hook URL secret values.
- Health check fails:
- verify app env vars (`DATABASE_URL` etc).
- verify upstream route and Nginx Proxy Manager mapping.
- verify DB reachable from VM/network.
---
## 10) Execution log template (fill as you go)
Copy/paste this section and fill values:
```md
# Dokploy VM Setup Execution Log
Date:
Operator:
VM IP:
Ubuntu version (`lsb_release -a`):
## Step 1 - OS prep
- Result:
- Notes:
## Step 2 - Docker install
- `docker --version`:
- `docker compose version`:
- Result:
## Step 3 - Firewall
- `ufw status verbose`:
- Result:
## Step 4 - Dokploy install
- Dokploy UI reachable: yes/no
- Result:
## Step 5 - Hardening
- SSH hardened: yes/no
- fail2ban status:
- Result:
## Step 6 - Dokploy apps
- Web app created: yes/no
- Scheduler app created: yes/no
- Hooks copied: yes/no
## Step 7 - Gitea secrets
- Secrets completed: yes/no
## Step 8 - First deploy
- Workflow URL:
- Result:
- Health checks:
## Issues encountered
-
## Final status
- Ready for NPM/domain wiring: yes/no
```
---
## 11) Safety notes
- Do not share raw credentials/tokens in chat or docs.
- Prefer SSH keys over passwords.
- Keep Dokploy host updated weekly (`sudo apt update && sudo apt -y upgrade`).
- Snapshot VM before major config changes.