Compare commits
2 Commits
f8e426542d
...
52af2a755c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52af2a755c | ||
|
|
54c46dd5ac |
@ -1,4 +1,4 @@
|
|||||||
name: Build & Deploy Fiddy (Dokploy)
|
name: Build & Deploy Fiddy (SSH Compose)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -50,35 +50,40 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
DEPLOY_PATH: /opt/fiddy
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: Trigger Dokploy Deploy
|
- name: Install SSH key
|
||||||
env:
|
|
||||||
DOKPLOY_DEPLOY_HOOK: ${{ secrets.DOKPLOY_DEPLOY_HOOK }}
|
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$DOKPLOY_DEPLOY_HOOK" ]; then
|
set -euo pipefail
|
||||||
echo "Missing DOKPLOY_DEPLOY_HOOK secret"
|
if [ -z "${{ secrets.DEPLOY_KEY }}" ]; then
|
||||||
|
echo "Missing DEPLOY_KEY secret"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
curl -fsS -X POST "$DOKPLOY_DEPLOY_HOOK" \
|
mkdir -p ~/.ssh
|
||||||
-H "Content-Type: application/json" \
|
printf "%s" "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||||
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
- name: Trigger Dokploy Scheduler Deploy
|
- name: Upload compose file
|
||||||
env:
|
|
||||||
DOKPLOY_SCHEDULER_DEPLOY_HOOK: ${{ secrets.DOKPLOY_SCHEDULER_DEPLOY_HOOK }}
|
|
||||||
IMAGE_TAG: ${{ github.sha }}
|
|
||||||
run: |
|
run: |
|
||||||
if [ -z "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" ]; then
|
set -euo pipefail
|
||||||
echo "DOKPLOY_SCHEDULER_DEPLOY_HOOK not set; skipping scheduler deploy trigger"
|
if [ -z "${{ secrets.DEPLOY_HOST }}" ] || [ -z "${{ secrets.DEPLOY_USER }}" ]; then
|
||||||
exit 0
|
echo "Missing DEPLOY_HOST or DEPLOY_USER secret"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
curl -fsS -X POST "$DOKPLOY_SCHEDULER_DEPLOY_HOOK" \
|
ssh "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" "mkdir -p '$DEPLOY_PATH'"
|
||||||
-H "Content-Type: application/json" \
|
scp docker-compose.yml "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:$DEPLOY_PATH/docker-compose.yml"
|
||||||
-d "{\"imageTag\":\"$IMAGE_TAG\"}"
|
|
||||||
|
- 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
|
- name: Wait for Ready Health Check
|
||||||
env:
|
env:
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
import { getSessionUser } from "@/lib/server/session";
|
||||||
import { requireActiveGroup } from "@/lib/server/groups";
|
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() {
|
export default async function GroupSettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import AppProviders from "@/components/app-providers";
|
import AppProviders from "@/features/app-shell/components/app-providers";
|
||||||
import AppFrame from "@/components/app-frame";
|
import AppFrame from "@/features/app-shell/components/app-frame";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Fiddy",
|
title: "Fiddy",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
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() {
|
export default async function Page() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getSessionUser } from "@/lib/server/session";
|
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() {
|
export default async function SettingsPage() {
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -2,8 +2,17 @@
|
|||||||
|
|
||||||
Domain-first frontend modules live here.
|
Domain-first frontend modules live here.
|
||||||
|
|
||||||
Current migrated domains:
|
Current structure:
|
||||||
- entries (components)
|
- `features/app-shell/components`: app frame, providers, navbar
|
||||||
- buckets (components)
|
- `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.
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { usePathname } from "next/navigation";
|
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_PATHS = new Set(["/login", "/register"]);
|
||||||
const NO_NAVBAR_PREFIXES = ["/invite"];
|
const NO_NAVBAR_PREFIXES = ["/invite"];
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 { useAuthContext } from "@/hooks/auth-context";
|
||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
|
|
||||||
@ -113,7 +113,7 @@ export default function Navbar() {
|
|||||||
onClick={() => setUserMenuOpen(prev => !prev)}
|
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"
|
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>
|
</button>
|
||||||
{userMenuOpen ? (
|
{userMenuOpen ? (
|
||||||
<div className="absolute right-0 mt-2 w-48 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
|
<div className="absolute right-0 mt-2 w-48 rounded-xl border border-accent-weak bg-panel p-3 text-sm shadow-lg">
|
||||||
@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useGroupsContext } from "@/hooks/groups-context";
|
import { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
import useBuckets from "@/features/buckets/hooks/use-buckets";
|
||||||
import useTags from "@/features/tags/hooks/use-tags";
|
import useTags from "@/features/tags/hooks/use-tags";
|
||||||
import NewBucketModal from "@/components/new-bucket-modal";
|
import NewBucketModal from "@/features/buckets/components/new-bucket-modal";
|
||||||
import ConfirmSlideModal from "@/components/confirm-slide-modal";
|
import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal";
|
||||||
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
import { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import BucketCard from "./bucket-card";
|
import BucketCard from "./bucket-card";
|
||||||
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
import { useEntryMutation } from "@/hooks/entry-mutation-context";
|
||||||
|
|||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo, useRef, useState } 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 { bucketIcons } from "@/lib/shared/bucket-icons";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
|
|
||||||
type BucketForm = {
|
type BucketForm = {
|
||||||
name: string;
|
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"
|
className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
<div className="text-lg font-semibold">{title}</div>
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
<form
|
<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"
|
className="flex h-10 w-12 items-center justify-center rounded-full border border-accent-weak bg-surface text-lg"
|
||||||
onClick={() => setIconModalOpen(true)}
|
onClick={() => setIconModalOpen(true)}
|
||||||
>
|
>
|
||||||
{selectedIcon || "🚫"}
|
{selectedIcon || "🚫"}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
@ -192,7 +192,7 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on
|
|||||||
onClick={() => setIconModalOpen(false)}
|
onClick={() => setIconModalOpen(false)}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/shared/components/forms/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
|
|
||||||
export type EntriesFilters = {
|
export type EntriesFilters = {
|
||||||
amountMin: string;
|
amountMin: string;
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
apps/web/features/entries/components/entries-panel-modals.tsx
Normal file
200
apps/web/features/entries/components/entries-panel-modals.tsx
Normal 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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,58 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
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 useEntries from "@/features/entries/hooks/use-entries";
|
||||||
import useSchedules from "@/features/entries/hooks/use-schedules";
|
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 { useGroupsContext } from "@/hooks/groups-context";
|
||||||
import { useNotificationsContext } from "@/hooks/notifications-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 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 = {
|
type ListProgressSignalProps = {
|
||||||
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
|
|
||||||
}: {
|
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
shownCount: number;
|
shownCount: number;
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
noun: "entries" | "schedules";
|
noun: "entries" | "schedules";
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
function ListProgressSignal({ hasMore, shownCount, totalCount, noun }: ListProgressSignalProps) {
|
||||||
if (totalCount <= 0) return null;
|
if (totalCount <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -73,7 +44,6 @@ function ListProgressSignal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function EntriesPanel() {
|
export default function EntriesPanel() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const { groups, activeGroupId } = useGroupsContext();
|
const { groups, activeGroupId } = useGroupsContext();
|
||||||
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId);
|
||||||
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
|
const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId);
|
||||||
@ -82,7 +52,6 @@ export default function EntriesPanel() {
|
|||||||
const { notifyEntryMutation } = useEntryMutation();
|
const { notifyEntryMutation } = useEntryMutation();
|
||||||
const { tags: tagSuggestions } = useTags(activeGroupId);
|
const { tags: tagSuggestions } = useTags(activeGroupId);
|
||||||
const { settings: groupSettings } = useGroupSettings(activeGroupId);
|
const { settings: groupSettings } = useGroupSettings(activeGroupId);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
const activeGroup = groups.find(group => group.id === activeGroupId) || null;
|
||||||
const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage));
|
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 pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10));
|
||||||
|
|
||||||
const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES");
|
const {
|
||||||
const [filters, setFilters] = useState<EntriesFilters>(EMPTY_FILTERS);
|
entryTab,
|
||||||
const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize);
|
setEntryTab,
|
||||||
const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize);
|
filters,
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
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 {
|
||||||
const [newScheduleOpen, setNewScheduleOpen] = useState(false);
|
newEntryOpen,
|
||||||
const [entryDetailsOpen, setEntryDetailsOpen] = useState(false);
|
setNewEntryOpen,
|
||||||
const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false);
|
newScheduleOpen,
|
||||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
setNewScheduleOpen,
|
||||||
const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY");
|
entryDetailsOpen,
|
||||||
|
setEntryDetailsOpen,
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState<number | null>(null);
|
scheduleDetailsOpen,
|
||||||
const [selectedEntryIndex, setSelectedEntryIndex] = useState<number | null>(null);
|
setScheduleDetailsOpen,
|
||||||
const [selectedScheduleId, setSelectedScheduleId] = useState<number | null>(null);
|
confirmDeleteOpen,
|
||||||
|
setConfirmDeleteOpen,
|
||||||
const [entryRemovedTags, setEntryRemovedTags] = useState<string[]>([]);
|
deleteTarget,
|
||||||
const [scheduleRemovedTags, setScheduleRemovedTags] = useState<string[]>([]);
|
setDeleteTarget,
|
||||||
|
selectedEntryIndex,
|
||||||
const [entryForm, setEntryForm] = useState({
|
entryRemovedTags,
|
||||||
amountDollars: "",
|
setEntryRemovedTags,
|
||||||
occurredAt: today,
|
scheduleRemovedTags,
|
||||||
necessity: "NECESSARY",
|
setScheduleRemovedTags,
|
||||||
notes: "",
|
entryForm,
|
||||||
tags: [] as string[],
|
setEntryForm,
|
||||||
entryType: "SPENDING" as "SPENDING" | "INCOME"
|
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>({
|
const listCounts = useMemo(() => {
|
||||||
amountDollars: "",
|
return {
|
||||||
startsOn: today,
|
entriesShown: visibleEntries.length,
|
||||||
necessity: "NECESSARY",
|
entriesTotal: filteredEntries.length,
|
||||||
notes: "",
|
schedulesShown: visibleSchedules.length,
|
||||||
tags: [],
|
schedulesTotal: filteredSchedules.length
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
}, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]);
|
}, [filteredEntries.length, filteredSchedules.length, visibleEntries.length, visibleSchedules.length]);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="panel panel-accent p-4">
|
<div className="panel panel-accent p-4">
|
||||||
<div className="card-header">
|
<EntriesPanelHeader
|
||||||
<ToggleButtonGroup
|
entryTab={entryTab}
|
||||||
value={entryTab}
|
activeGroupId={activeGroupId}
|
||||||
onChange={setEntryTab}
|
activeFilterCount={activeFilterCount}
|
||||||
ariaLabel="Entries and schedules tab"
|
onTabChange={setEntryTab}
|
||||||
sizeClassName="px-3 py-2 text-xs font-semibold"
|
onOpenFilters={() => setFilterOpen(true)}
|
||||||
options={[
|
onOpenCreate={() => {
|
||||||
{ value: "ENTRIES", label: "Entries", className: "mr-[-10px] w-24" },
|
if (entryTab === "ENTRIES") {
|
||||||
{ value: "SCHEDULES", label: "Schedules", className: "w-24" }
|
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" ? (
|
{entryTab === "ENTRIES" ? (
|
||||||
<>
|
<>
|
||||||
<EntriesList
|
<EntriesList
|
||||||
@ -633,8 +180,8 @@ export default function EntriesPanel() {
|
|||||||
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
<div ref={entriesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
||||||
<ListProgressSignal
|
<ListProgressSignal
|
||||||
hasMore={hasMoreEntries}
|
hasMore={hasMoreEntries}
|
||||||
shownCount={visibleEntries.length}
|
shownCount={listCounts.entriesShown}
|
||||||
totalCount={filteredEntries.length}
|
totalCount={listCounts.entriesTotal}
|
||||||
noun="entries"
|
noun="entries"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@ -652,127 +199,70 @@ export default function EntriesPanel() {
|
|||||||
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
<div ref={schedulesLoadSentinelRef} className="h-1" aria-hidden="true" />
|
||||||
<ListProgressSignal
|
<ListProgressSignal
|
||||||
hasMore={hasMoreSchedules}
|
hasMore={hasMoreSchedules}
|
||||||
shownCount={visibleSchedules.length}
|
shownCount={listCounts.schedulesShown}
|
||||||
totalCount={filteredSchedules.length}
|
totalCount={listCounts.schedulesTotal}
|
||||||
noun="schedules"
|
noun="schedules"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NewEntryModal
|
|
||||||
isOpen={newEntryOpen && Boolean(activeGroupId)}
|
<EntriesPanelModals
|
||||||
form={entryForm}
|
activeGroupId={activeGroupId}
|
||||||
error={entriesError}
|
entriesError={entriesError}
|
||||||
onClose={() => setNewEntryOpen(false)}
|
schedulesError={schedulesError}
|
||||||
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}
|
|
||||||
tagSuggestions={tagSuggestions}
|
tagSuggestions={tagSuggestions}
|
||||||
canManageTags={canManageTags}
|
canManageTags={canManageTags}
|
||||||
emptyTagActionLabel={emptyTagActionLabel}
|
emptyTagActionLabel={emptyTagActionLabel}
|
||||||
onEmptyTagAction={handleEmptyTagAction}
|
handleEmptyTagAction={handleEmptyTagAction}
|
||||||
onClearFilters={clearFilters}
|
filterOpen={filterOpen}
|
||||||
onFilterAddTag={tag => setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))}
|
setFilterOpen={setFilterOpen}
|
||||||
onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))}
|
filters={filters}
|
||||||
onClose={() => setFilterOpen(false)}
|
setFilters={setFilters}
|
||||||
/>
|
activeFilterCount={activeFilterCount}
|
||||||
<ConfirmSlideModal
|
clearFilters={clearFilters}
|
||||||
isOpen={confirmDeleteOpen}
|
onFilterAddTag={onFilterAddTag}
|
||||||
title={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
onFilterToggleTag={onFilterToggleTag}
|
||||||
description={deleteTarget === "ENTRY" ? "This will permanently remove the entry and its tags." : "This will permanently remove the schedule and its tags."}
|
newEntryOpen={newEntryOpen}
|
||||||
confirmLabel={deleteTarget === "ENTRY" ? "Delete entry" : "Delete schedule"}
|
setNewEntryOpen={setNewEntryOpen}
|
||||||
onClose={() => setConfirmDeleteOpen(false)}
|
entryForm={entryForm}
|
||||||
onConfirm={() => {
|
setEntryForm={setEntryForm}
|
||||||
setConfirmDeleteOpen(false);
|
submitNewEntry={submitNewEntry}
|
||||||
confirmDelete();
|
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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
23
apps/web/features/entries/components/entries-panel.types.ts
Normal file
23
apps/web/features/entries/components/entries-panel.types.ts
Normal 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;
|
||||||
31
apps/web/features/entries/components/entries-panel.utils.ts
Normal file
31
apps/web/features/entries/components/entries-panel.utils.ts
Normal 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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/shared/components/forms/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
import DatePicker from "@/components/date-picker";
|
import DatePicker from "@/shared/components/forms/date-picker";
|
||||||
|
|
||||||
export type EntryDetailsForm = {
|
export type EntryDetailsForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
necessity: string;
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
entryType: "SPENDING" | "INCOME";
|
||||||
@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/shared/components/forms/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
import DatePicker from "@/components/date-picker";
|
import DatePicker from "@/shared/components/forms/date-picker";
|
||||||
|
|
||||||
type NewEntryForm = {
|
type NewEntryForm = {
|
||||||
amountDollars: string;
|
amountDollars: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
necessity: string;
|
necessity: "NECESSARY" | "BOTH" | "UNNECESSARY";
|
||||||
notes: string;
|
notes: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
entryType: "SPENDING" | "INCOME";
|
entryType: "SPENDING" | "INCOME";
|
||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import DatePicker from "@/components/date-picker";
|
import DatePicker from "@/shared/components/forms/date-picker";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/shared/components/forms/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
export type NewScheduleForm = {
|
export type NewScheduleForm = {
|
||||||
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import DatePicker from "@/components/date-picker";
|
import DatePicker from "@/shared/components/forms/date-picker";
|
||||||
import TagInput from "@/components/tag-input";
|
import TagInput from "@/shared/components/forms/tag-input";
|
||||||
import ToggleButtonGroup from "@/components/toggle-button-group";
|
import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group";
|
||||||
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types";
|
||||||
|
|
||||||
export type ScheduleDetailsForm = {
|
export type ScheduleDetailsForm = {
|
||||||
407
apps/web/features/entries/components/use-entries-panel-crud.ts
Normal file
407
apps/web/features/entries/components/use-entries-panel-crud.ts
Normal 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>;
|
||||||
@ -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>;
|
||||||
@ -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]);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
apps/web/features/groups/components/group-settings-modals.tsx
Normal file
213
apps/web/features/groups/components/group-settings-modals.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/web/features/groups/components/group-settings.types.ts
Normal file
15
apps/web/features/groups/components/group-settings.types.ts
Normal 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;
|
||||||
|
};
|
||||||
42
apps/web/features/groups/components/group-settings.utils.ts
Normal file
42
apps/web/features/groups/components/group-settings.utils.ts
Normal 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";
|
||||||
|
}
|
||||||
@ -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>;
|
||||||
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
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 = {
|
type NotifyInput = {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@ -2,4 +2,9 @@
|
|||||||
|
|
||||||
Cross-domain reusable primitives only.
|
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.
|
Use this for generic components/hooks/lib that are not tied to a single business domain.
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import type { Config } from "tailwindcss";
|
|||||||
export default {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"./components/**/*.{ts,tsx}",
|
|
||||||
"./features/**/*.{ts,tsx}",
|
"./features/**/*.{ts,tsx}",
|
||||||
"./shared/**/*.{ts,tsx}"
|
"./shared/**/*.{ts,tsx}"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,5 +6,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3010:3000"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
scheduler:
|
||||||
|
image: git.nicosaya.com/nalalangan/fiddy/scheduler:${IMAGE_TAG}
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@ -152,3 +152,6 @@ Primary outcomes:
|
|||||||
### Risks / Notes to Revisit
|
### Risks / Notes to Revisit
|
||||||
- Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes.
|
- 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.
|
- 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.
|
||||||
|
|||||||
326
docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md
Normal file
326
docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user