diff --git a/apps/web/app/groups/settings/page.tsx b/apps/web/app/groups/settings/page.tsx index 3c67326..e97780f 100644 --- a/apps/web/app/groups/settings/page.tsx +++ b/apps/web/app/groups/settings/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getSessionUser } from "@/lib/server/session"; import { requireActiveGroup } from "@/lib/server/groups"; -import GroupSettingsContent from "@/components/group-settings-content"; +import GroupSettingsContent from "@/features/groups/components/group-settings-content"; export default async function GroupSettingsPage() { const user = await getSessionUser(); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index b50af62..3b6d6bc 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,7 +1,7 @@ import "./globals.css"; import type { Metadata } from "next"; -import AppProviders from "@/components/app-providers"; -import AppFrame from "@/components/app-frame"; +import AppProviders from "@/features/app-shell/components/app-providers"; +import AppFrame from "@/features/app-shell/components/app-frame"; export const metadata: Metadata = { title: "Fiddy", diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index e648504..9a3b656 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { getSessionUser } from "@/lib/server/session"; -import DashboardContent from "@/components/dashboard-content"; +import DashboardContent from "@/features/dashboard/components/dashboard-content"; export default async function Page() { const user = await getSessionUser(); diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 1defb51..6d58eba 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -1,6 +1,6 @@ import { redirect } from "next/navigation"; import { getSessionUser } from "@/lib/server/session"; -import SettingsContent from "@/components/settings-content"; +import SettingsContent from "@/features/user-settings/components/settings-content"; export default async function SettingsPage() { const user = await getSessionUser(); diff --git a/apps/web/components/group-settings-content.tsx b/apps/web/components/group-settings-content.tsx deleted file mode 100644 index 653d744..0000000 --- a/apps/web/components/group-settings-content.tsx +++ /dev/null @@ -1,1025 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import useTags from "@/features/tags/hooks/use-tags"; -import useGroupSettings from "@/features/groups/hooks/use-group-settings"; -import useGroupMembers from "@/features/groups/hooks/use-group-members"; -import useGroupInvites from "@/features/groups/hooks/use-group-invites"; -import useGroupAudit from "@/features/groups/hooks/use-group-audit"; -import { useGroupsContext } from "@/hooks/groups-context"; -import TagInput from "@/components/tag-input"; -import { useNotificationsContext } from "@/hooks/notifications-context"; -import ConfirmSlideModal from "@/components/confirm-slide-modal"; -import ConfirmRetypeModal from "@/components/confirm-retype-modal"; -import { groupsDelete, groupsRename } from "@/lib/client/groups"; -import ToggleButtonGroup from "@/components/toggle-button-group"; - -export default function GroupSettingsContent({ groupId }: { 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([]); - const [toggleRemoveTags, setToggleRemoveTags] = useState([]); - 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<{ userId: number; name: string } | null>(null); - const [confirmTransfer, setConfirmTransfer] = useState<{ userId: number; name: string } | 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<"UNLIMITED" | "SINGLE">("UNLIMITED"); - const [auditOpen, setAuditOpen] = useState(false); - const [confirmDeleteInvite, setConfirmDeleteInvite] = useState<{ id: number; token: string } | null>(null); - const [auditFilters, setAuditFilters] = useState(["members", "tags", "settings", "entries"] as Array<"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(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; - - useEffect(() => { - setRenameValue(group?.name || ""); - }, [group?.name]); - - useEffect(() => { - setLocalAllowMemberTagManage(settings.allowMemberTagManage); - setLocalJoinPolicy(settings.joinPolicy); - }, [settings.allowMemberTagManage, settings.joinPolicy]); - - 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 handleRemoveTag(tag: string) { - const ok = await removeTag(tag); - if (ok) notify({ title: "Tag removed", message: `#${tag}`, tone: "danger" }); - } - - 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: "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED") { - 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); - } - - function handleCloseRenameModal() { - setRenameModalOpen(false); - setRenameValue(group?.name || ""); - } - - 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" }); - } - } - - 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`; - } - - function isInviteExpired(expiresAt: string) { - const expiry = new Date(expiresAt).getTime(); - if (Number.isNaN(expiry)) return false; - return Date.now() > expiry; - } - - function eventCategory(eventType: string) { - const upper = eventType.toUpperCase(); - if (upper.includes("SPENDING") || upper.includes("ENTRY")) return "entries" as const; - if (upper.includes("TAG")) return "tags" as const; - if (upper.includes("SETTING") || upper.includes("RENAMED")) return "settings" as const; - return "members" as const; - } - - 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(); - 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 memberCount = members.length; - const showLeaveGroup = role !== "GROUP_OWNER" && !isLastAdmin; - - 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("/"); - } - - if (!group) { - return ( -
-
Loading group settings...
-
- ); - } - - const visibleTags = showAllTags ? tags : tags.slice(0, 5); - const hasMoreTags = tags.length > 5; - const tagsScrollable = showAllTags && tags.length > 15; - - return ( - <> -
-
-
-

{group.name} settings

-

Manage tags and permissions for this group.

-
- -
-
-
-
General Settings
-
-
-
-
-
Group name
-
{group.name}
-
- {isAdmin ? ( - - ) : null} -
- {!isAdmin ? ( -
Only admins can rename the group.
- ) : null} -
- {isAdmin ? ( - <> -
-
Allow members to manage tags
- -
-
- - ) : null} -
-
Group Tags ({tags.length})
-
- {showAllTags ? ( - - ) : null} - {canManageTags ? ( - - ) : null} -
-
-
-
- {visibleTags.map(tag => ( - - #{tag} - - ))} - {!showAllTags && hasMoreTags ? ( - - ) : null} - {!tags.length ?
No tags yet.
: null} -
-
-
-
- - {isAdmin ? ( -
-
-
Join and Invites
-
-
-
-
Join policy
- handleJoinPolicyChange(policy)} - 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" } - ]} - /> -
-
-
-
-
Join Requests ({requests.length})
- {!requests.length ? ( -
No pending requests.
- ) : ( -
- {requests.map(request => ( -
-
-
{request.displayName || request.email}
-
{request.email}
-
-
- - -
-
- ))} -
- )} -
-
-
-
Invite links
-
- - - -
- {!links.length ? ( -
No invite links yet.
- ) : ( -
- {links.map(link => ( -
-
-
Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}
- {(() => { - const showRevive = link.revokedAt || isInviteExpired(link.expiresAt) || (link.singleUse && link.usedAt); - const showRevoke = !showRevive && !link.revokedAt && !(link.singleUse && link.usedAt) && !isInviteExpired(link.expiresAt); - const options = [ - { - value: "COPY", - label: "Copy link", - className: "btn-outline-accent", - disabled: localJoinPolicy === "NOT_ACCEPTING", - onClick: () => handleCopyInvite(link.token) - }, - ...(showRevive - ? [{ - value: "REVIVE", - label: "Revive", - className: "btn-outline-accent", - disabled: localJoinPolicy === "NOT_ACCEPTING", - onClick: () => reviveInvite(link.id, inviteTtlDays) - }] - : showRevoke - ? [{ - value: "REVOKE", - label: "Revoke", - className: "border border-red-400/60 bg-red-500/10 text-red-200", - onClick: () => revokeInvite(link.id) - }] - : []), - { - value: "DELETE", - label: "Delete", - className: "border border-red-400/60 bg-red-500/10 text-red-200", - onClick: () => setConfirmDeleteInvite({ id: link.id, token: link.token }) - } - ]; - - return ( - - ); - })()} -
-
- Expires {formatInviteExpiry(link.expiresAt)} - Uses: {link.singleUse ? "1 use" : "Unlimited"} - Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : isInviteExpired(link.expiresAt) ? "Expired" : "Active"} -
-
- ))} -
- )} -
-
- ) : null} - -
-
-
Members
-
{memberCount} total
-
-
- {members.map(member => { - const isSelf = member.userId === currentUserId; - const privilegeLabel = member.role === "GROUP_OWNER" - ? "Owner - Full control" - : member.role === "GROUP_ADMIN" - ? "Admin - Manage members" - : "Member - Entries only"; - return ( -
-
-
- {member.displayName || member.email} - {isSelf ? You : null} -
-
{member.email}
-
{privilegeLabel}
-
-
- {isAdmin ? ( - - ) : ( - {member.role} - )} - {isAdmin && member.role !== "GROUP_OWNER" ? ( - - ) : null} - {isOwner && member.role !== "GROUP_OWNER" ? ( - - ) : null} -
-
- ); - })} -
-
- -
-
-
Danger zone
-
-
- {showLeaveGroup ? ( - - ) : null} - -
- {!isOwner ?
Only the owner can delete this group.
: null} -
- - {canViewAudit ? ( -
-
-
Audit log
- -
- {auditOpen ? ( - <> -
-
-
Total logs
-
{events.length}
-
-
-
Logs today
-
{logsToday}
-
- -
-
- {([ - { key: "entries", label: "Entries" }, - { key: "members", label: "Members" }, - { key: "tags", label: "Tags" }, - { key: "settings", label: "Settings" } - ] as const).map(filter => ( - - ))} -
-
-
- - - - - setAuditQuery(e.target.value)} - /> -
-
-
- - - - - - - setAuditFrom(e.target.value)} - /> -
-
- - - - - - - setAuditTo(e.target.value)} - /> -
-
-
- {!filteredEvents.length ? ( -
No audit events match your filters.
- ) : ( -
- {filteredEvents.slice(0, auditLimit).map(event => ( -
-
{event.eventType}
-
- Actor: {getMemberLabel(event.actorUserId)} - Role: {event.actorRole ?? "-"} - {new Date(event.createdAt).toLocaleString()} -
-
- ))} - {filteredEvents.length > auditLimit ? ( - - ) : null} -
- )} - - ) : ( -
Audit log is collapsed.
- )} -
- ) : null} - - {renameModalOpen ? ( -
-
event.stopPropagation()} - onKeyDown={event => { - if (event.key === "Escape") handleCloseRenameModal(); - if (event.key === "Enter" && renameDirty && isAdmin && renameValue.trim()) setConfirmRenameOpen(true); - }} - role="dialog" - tabIndex={-1} - > - -
Change group name
- setRenameValue(e.target.value)} - placeholder="Group name" - /> - {renameDirty ? ( -
- You have unsaved changes. -
- ) : null} -
- {renameDirty ? ( - <> - - - - ) : ( - - )} -
-
-
- ) : null} - - {tagModalOpen ? ( -
setTagModalOpen(false)}> -
event.stopPropagation()} - onKeyDown={event => { - if (event.key === "Escape") setTagModalOpen(false); - // if (event.key === "Enter" && !event.shiftKey && pendingTags.length && canManageTags) handleSaveTags(); - }} - role="dialog" - tabIndex={-1} - > -
-
Edit tags
- -
-
- setPendingTags(prev => prev.filter(item => item !== tag))} - onAddTag={tag => setPendingTags(prev => prev.includes(tag) ? prev : [...prev, tag])} - /> - - {!canManageTags ? ( -
Only admins can add new tags.
- ) : null} -
-
Existing tags
-
-
- {tags.map(tag => ( - - ))} - {!tags.length ?
No tags yet.
: null} -
-
- {toggleRemoveTags.length ? ( - - ) : null} -
-
-
- ) : null} - setConfirmDeleteOpen(false)} - onConfirm={() => { - setConfirmDeleteOpen(false); - handleConfirmDelete(); - }} - /> - setConfirmDeleteInvite(null)} - onConfirm={async () => { - if (!confirmDeleteInvite) return; - const target = confirmDeleteInvite; - setConfirmDeleteInvite(null); - const ok = await deleteInvite(target.id); - if (ok) notify({ title: "Invite deleted", tone: "danger" }); - }} - /> - setConfirmRenameOpen(false)} - onConfirm={handleRenameGroup} - /> - setConfirmLeaveOpen(false)} - onConfirm={async () => { - setConfirmLeaveOpen(false); - const ok = await leave(); - if (ok) { - notify({ title: "Left group" }); - router.push("/"); - } - }} - /> - setConfirmKick(null)} - onConfirm={async () => { - 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" }); - }} - /> - setConfirmTransfer(null)} - onConfirm={async () => { - if (!confirmTransfer) return; - const target = confirmTransfer; - setConfirmTransfer(null); - const ok = await transferOwner(target.userId); - if (ok) notify({ title: "Ownership transferred", message: target.name }); - }} - /> - setConfirmDeleteGroupOpen(false)} - onConfirm={handleDeleteGroup} - /> -
- - ); -} diff --git a/apps/web/features/README.md b/apps/web/features/README.md index e3b6959..f8f8a87 100644 --- a/apps/web/features/README.md +++ b/apps/web/features/README.md @@ -2,8 +2,17 @@ Domain-first frontend modules live here. -Current migrated domains: -- entries (components) -- buckets (components) +Current structure: +- `features/app-shell/components`: app frame, providers, navbar +- `features/dashboard/components`: dashboard composition +- `features/user-settings/components`: user settings UI +- `features/auth/hooks`: auth hook layer +- `features/groups/components` + `features/groups/hooks`: group settings UI and group APIs +- `features/entries/components` + `features/entries/hooks`: entries/schedules UI and APIs +- `features/buckets/components` + `features/buckets/hooks`: bucket UI and APIs +- `features/tags/hooks`: tag APIs -Future migrations should move domain-specific components/hooks/lib into these folders incrementally. +Rules: +- Put domain-owned UI under its domain folder. +- Keep hooks in the same domain whenever possible. +- Use `shared/*` only for cross-domain primitives. diff --git a/apps/web/components/app-frame.tsx b/apps/web/features/app-shell/components/app-frame.tsx similarity index 91% rename from apps/web/components/app-frame.tsx rename to apps/web/features/app-shell/components/app-frame.tsx index 34b6e12..c3e16a7 100644 --- a/apps/web/components/app-frame.tsx +++ b/apps/web/features/app-shell/components/app-frame.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { usePathname } from "next/navigation"; -import Navbar from "@/components/navbar"; +import Navbar from "@/features/app-shell/components/navbar"; const NO_NAVBAR_PATHS = new Set(["/login", "/register"]); const NO_NAVBAR_PREFIXES = ["/invite"]; diff --git a/apps/web/components/app-providers.tsx b/apps/web/features/app-shell/components/app-providers.tsx similarity index 100% rename from apps/web/components/app-providers.tsx rename to apps/web/features/app-shell/components/app-providers.tsx diff --git a/apps/web/components/navbar.tsx b/apps/web/features/app-shell/components/navbar.tsx similarity index 98% rename from apps/web/components/navbar.tsx rename to apps/web/features/app-shell/components/navbar.tsx index 157719b..13640a9 100644 --- a/apps/web/components/navbar.tsx +++ b/apps/web/features/app-shell/components/navbar.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import GroupDropdown from "@/components/group-dropdown"; +import GroupDropdown from "@/features/groups/components/group-dropdown"; import { useAuthContext } from "@/hooks/auth-context"; import { useGroupsContext } from "@/hooks/groups-context"; @@ -113,7 +113,7 @@ export default function Navbar() { onClick={() => setUserMenuOpen(prev => !prev)} className="flex h-9 w-9 items-center justify-center rounded-full border border-accent-weak bg-panel text-sm text-muted hover:border-accent" > - 👤 + 👤 {userMenuOpen ? (
diff --git a/apps/web/features/buckets/components/buckets-panel.tsx b/apps/web/features/buckets/components/buckets-panel.tsx index 0f504ac..7ef02a2 100644 --- a/apps/web/features/buckets/components/buckets-panel.tsx +++ b/apps/web/features/buckets/components/buckets-panel.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from "react"; import { useGroupsContext } from "@/hooks/groups-context"; import useBuckets from "@/features/buckets/hooks/use-buckets"; import useTags from "@/features/tags/hooks/use-tags"; -import NewBucketModal from "@/components/new-bucket-modal"; -import ConfirmSlideModal from "@/components/confirm-slide-modal"; +import NewBucketModal from "@/features/buckets/components/new-bucket-modal"; +import ConfirmSlideModal from "@/shared/components/modals/confirm-slide-modal"; import { bucketIcons } from "@/lib/shared/bucket-icons"; import BucketCard from "./bucket-card"; import { useEntryMutation } from "@/hooks/entry-mutation-context"; diff --git a/apps/web/components/new-bucket-modal.tsx b/apps/web/features/buckets/components/new-bucket-modal.tsx similarity index 97% rename from apps/web/components/new-bucket-modal.tsx rename to apps/web/features/buckets/components/new-bucket-modal.tsx index cec0b35..75597e0 100644 --- a/apps/web/components/new-bucket-modal.tsx +++ b/apps/web/features/buckets/components/new-bucket-modal.tsx @@ -2,9 +2,9 @@ import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; -import TagInput from "@/components/tag-input"; +import TagInput from "@/shared/components/forms/tag-input"; import { bucketIcons } from "@/lib/shared/bucket-icons"; -import ToggleButtonGroup from "@/components/toggle-button-group"; +import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group"; type BucketForm = { name: string; @@ -70,7 +70,7 @@ export default function NewBucketModal({ isOpen, title, form, error, onClose, on className="absolute right-3 top-3 rounded-lg btn-outline-accent px-2 py-1 text-sm" aria-label="Close" > - ✕ + ✕
{title}
setIconModalOpen(true)} > - {selectedIcon || "🚫"} + {selectedIcon || "🚫"}
setIconModalOpen(false)} aria-label="Close" > - ✕ + ✕
void; + onOpenFilters: () => void; + onOpenCreate: () => void; +}; + +export default function EntriesPanelHeader({ + entryTab, + activeGroupId, + activeFilterCount, + onTabChange, + onOpenFilters, + onOpenCreate +}: EntriesPanelHeaderProps) { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/features/entries/components/entries-panel-modals.tsx b/apps/web/features/entries/components/entries-panel-modals.tsx new file mode 100644 index 0000000..4855df7 --- /dev/null +++ b/apps/web/features/entries/components/entries-panel-modals.tsx @@ -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>; + filters: EntriesFilters; + setFilters: Dispatch>; + activeFilterCount: number; + clearFilters: () => void; + onFilterAddTag: (tag: string) => void; + onFilterToggleTag: (tag: string) => void; + + newEntryOpen: boolean; + setNewEntryOpen: Dispatch>; + entryForm: EntryFormState; + setEntryForm: Dispatch>; + submitNewEntry: (event: FormEvent) => Promise; + amountInputRef: MutableRefObject; + tagsInputRef: MutableRefObject; + + newScheduleOpen: boolean; + setNewScheduleOpen: Dispatch>; + scheduleForm: ScheduleFormState; + setScheduleForm: Dispatch>; + submitNewSchedule: (event: FormEvent) => Promise; + + entryDetailsOpen: boolean; + setEntryDetailsOpen: Dispatch>; + entryDetailsForm: EntryDetailsFormState; + setEntryDetailsForm: Dispatch>; + entryDetailsOriginal: EntryDetailsFormState | null; + hasEntryChanges: () => boolean; + submitEntryUpdate: (event: FormEvent) => Promise; + entryRemovedTags: string[]; + setEntryRemovedTags: Dispatch>; + prevEntry: () => void; + nextEntry: () => void; + selectedEntryIndex: number | null; + filteredEntriesCount: number; + + scheduleDetailsOpen: boolean; + setScheduleDetailsOpen: Dispatch>; + scheduleDetailsForm: ScheduleDetailsFormState; + setScheduleDetailsForm: Dispatch>; + scheduleDetailsOriginal: ScheduleDetailsFormState | null; + hasScheduleChanges: () => boolean; + submitScheduleUpdate: (event: FormEvent) => Promise; + scheduleRemovedTags: string[]; + setScheduleRemovedTags: Dispatch>; + + confirmDeleteOpen: boolean; + setConfirmDeleteOpen: Dispatch>; + deleteTarget: DeleteTarget; + setDeleteTarget: Dispatch>; + confirmDelete: () => Promise; +}; + +export default function EntriesPanelModals(props: EntriesPanelModalsProps) { + return ( + <> + 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} + /> + 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} + /> + 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} + /> + 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} + /> + props.setFilterOpen(false)} + /> + props.setConfirmDeleteOpen(false)} + onConfirm={() => { + props.setConfirmDeleteOpen(false); + props.confirmDelete(); + }} + /> + + ); +} diff --git a/apps/web/features/entries/components/entries-panel.tsx b/apps/web/features/entries/components/entries-panel.tsx index 8aa78d3..1a652e2 100644 --- a/apps/web/features/entries/components/entries-panel.tsx +++ b/apps/web/features/entries/components/entries-panel.tsx @@ -1,58 +1,29 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useMemo } from "react"; +import EntriesList from "@/features/entries/components/entries-list"; +import EntriesPanelHeader from "@/features/entries/components/entries-panel-header"; +import EntriesPanelModals from "@/features/entries/components/entries-panel-modals"; +import SchedulesList from "@/features/entries/components/schedules-list"; +import useEntriesPanelCrud from "@/features/entries/components/use-entries-panel-crud"; +import useEntriesPanelFilters from "@/features/entries/components/use-entries-panel-filters"; import useEntries from "@/features/entries/hooks/use-entries"; import useSchedules from "@/features/entries/hooks/use-schedules"; +import useGroupSettings from "@/features/groups/hooks/use-group-settings"; +import useTags from "@/features/tags/hooks/use-tags"; +import { useEntryMutation } from "@/hooks/entry-mutation-context"; import { useGroupsContext } from "@/hooks/groups-context"; import { useNotificationsContext } from "@/hooks/notifications-context"; -import { useEntryMutation } from "@/hooks/entry-mutation-context"; -import useTags from "@/features/tags/hooks/use-tags"; -import useGroupSettings from "@/features/groups/hooks/use-group-settings"; import useUserSettings from "@/hooks/use-user-settings"; -import ToggleButtonGroup from "@/components/toggle-button-group"; -import NewEntryModal from "@/components/new-entry-modal"; -import EntryDetailsModal from "@/components/entry-details-modal"; -import NewScheduleModal, { type NewScheduleForm } from "@/components/new-schedule-modal"; -import ScheduleDetailsModal, { type ScheduleDetailsForm } from "@/components/schedule-details-modal"; -import EntriesList from "@/features/entries/components/entries-list"; -import SchedulesList from "@/features/entries/components/schedules-list"; -import EntriesFilterModal, { type EntriesFilters } from "@/features/entries/components/entries-filter-modal"; -import ConfirmSlideModal from "@/components/confirm-slide-modal"; -const EMPTY_FILTERS: EntriesFilters = { - amountMin: "", - amountMax: "", - dateFrom: "", - dateTo: "", - necessity: "ANY", - notesQuery: "", - tags: [], - tagsMode: "ANY" -}; - -function normalizeTagList(tags: string[]) { - return tags.map(tag => tag.toLowerCase()).sort().join("|"); -} - -function isEditableTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false; - if (target.isContentEditable) return true; - const tag = target.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; -} - -function ListProgressSignal({ - hasMore, - shownCount, - totalCount, - noun -}: { +type ListProgressSignalProps = { hasMore: boolean; shownCount: number; totalCount: number; noun: "entries" | "schedules"; -}) { +}; + +function ListProgressSignal({ hasMore, shownCount, totalCount, noun }: ListProgressSignalProps) { if (totalCount <= 0) return null; return ( @@ -73,7 +44,6 @@ function ListProgressSignal({ } export default function EntriesPanel() { - const today = new Date().toISOString().slice(0, 10); const { groups, activeGroupId } = useGroupsContext(); const { entries, loading: entriesLoading, error: entriesError, createEntry, updateEntry, deleteEntry } = useEntries(activeGroupId); const { schedules, loading: schedulesLoading, error: schedulesError, createSchedule, updateSchedule, deleteSchedule } = useSchedules(activeGroupId); @@ -82,7 +52,6 @@ export default function EntriesPanel() { const { notifyEntryMutation } = useEntryMutation(); const { tags: tagSuggestions } = useTags(activeGroupId); const { settings: groupSettings } = useGroupSettings(activeGroupId); - const router = useRouter(); const activeGroup = groups.find(group => group.id === activeGroupId) || null; const canManageTags = Boolean(activeGroup && (activeGroup.role !== "MEMBER" || groupSettings.allowMemberTagManage)); @@ -92,533 +61,111 @@ export default function EntriesPanel() { const pageSize = Math.max(1, Number(userSettings.entryPanelPageSize || 10)); - const [entryTab, setEntryTab] = useState<"ENTRIES" | "SCHEDULES">("ENTRIES"); - const [filters, setFilters] = useState(EMPTY_FILTERS); - const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize); - const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize); - const [filterOpen, setFilterOpen] = useState(false); + const { + entryTab, + setEntryTab, + filters, + setFilters, + filterOpen, + setFilterOpen, + activeFilterCount, + filteredEntries, + filteredSchedules, + visibleEntries, + visibleSchedules, + hasMoreEntries, + hasMoreSchedules, + entriesLoadSentinelRef, + schedulesLoadSentinelRef, + clearFilters, + onFilterAddTag, + onFilterToggleTag + } = useEntriesPanelFilters({ entries, schedules, pageSize }); - const [newEntryOpen, setNewEntryOpen] = useState(false); - const [newScheduleOpen, setNewScheduleOpen] = useState(false); - const [entryDetailsOpen, setEntryDetailsOpen] = useState(false); - const [scheduleDetailsOpen, setScheduleDetailsOpen] = useState(false); - const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - const [deleteTarget, setDeleteTarget] = useState<"ENTRY" | "SCHEDULE">("ENTRY"); - - const [selectedEntryId, setSelectedEntryId] = useState(null); - const [selectedEntryIndex, setSelectedEntryIndex] = useState(null); - const [selectedScheduleId, setSelectedScheduleId] = useState(null); - - const [entryRemovedTags, setEntryRemovedTags] = useState([]); - const [scheduleRemovedTags, setScheduleRemovedTags] = useState([]); - - const [entryForm, setEntryForm] = useState({ - amountDollars: "", - occurredAt: today, - necessity: "NECESSARY", - notes: "", - tags: [] as string[], - entryType: "SPENDING" as "SPENDING" | "INCOME" + const { + newEntryOpen, + setNewEntryOpen, + newScheduleOpen, + setNewScheduleOpen, + entryDetailsOpen, + setEntryDetailsOpen, + scheduleDetailsOpen, + setScheduleDetailsOpen, + confirmDeleteOpen, + setConfirmDeleteOpen, + deleteTarget, + setDeleteTarget, + selectedEntryIndex, + entryRemovedTags, + setEntryRemovedTags, + scheduleRemovedTags, + setScheduleRemovedTags, + entryForm, + setEntryForm, + entryDetailsForm, + setEntryDetailsForm, + entryDetailsOriginal, + scheduleForm, + setScheduleForm, + scheduleDetailsForm, + setScheduleDetailsForm, + scheduleDetailsOriginal, + amountInputRef, + tagsInputRef, + handleEmptyTagAction, + hasEntryChanges, + hasScheduleChanges, + submitNewEntry, + submitNewSchedule, + openEntryDetails, + openScheduleDetails, + submitEntryUpdate, + submitScheduleUpdate, + confirmDelete, + prevEntry, + nextEntry + } = useEntriesPanelCrud({ + filteredEntries, + schedules, + createEntry, + updateEntry, + deleteEntry, + createSchedule, + updateSchedule, + deleteSchedule, + notify, + notifyEntryMutation, + canManageTags }); - const [entryDetailsForm, setEntryDetailsForm] = useState({ - amountDollars: "", - occurredAt: today, - necessity: "NECESSARY", - notes: "", - tags: [] as string[], - entryType: "SPENDING" as "SPENDING" | "INCOME" - }); - const [entryDetailsOriginal, setEntryDetailsOriginal] = useState(null); - const [scheduleForm, setScheduleForm] = useState({ - amountDollars: "", - startsOn: today, - necessity: "NECESSARY", - notes: "", - tags: [], - entryType: "SPENDING", - frequency: "MONTHLY", - intervalCount: 1, - endCondition: "NEVER", - endCount: "", - endDate: "", - createEntryNow: false - }); - const [scheduleDetailsForm, setScheduleDetailsForm] = useState({ - 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(null); - - const amountInputRef = useRef(null); - const tagsInputRef = useRef(null); - const entriesLoadSentinelRef = useRef(null); - const schedulesLoadSentinelRef = useRef(null); - - useEffect(() => { - setEntryVisibleCount(pageSize); - setScheduleVisibleCount(pageSize); - }, [pageSize]); - - const activeFilterCount = useMemo(() => { - let count = 0; - if (filters.amountMin) count += 1; - if (filters.amountMax) count += 1; - if (filters.dateFrom) count += 1; - if (filters.dateTo) count += 1; - if (filters.necessity !== "ANY") count += 1; - if (filters.notesQuery.trim()) count += 1; - if (filters.tags.length) count += 1; - return count; - }, [filters]); - - const filteredEntries = useMemo(() => { - const min = filters.amountMin ? Number(filters.amountMin) : null; - const max = filters.amountMax ? Number(filters.amountMax) : null; - const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null; - const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null; - const query = filters.notesQuery.trim().toLowerCase(); - const tagsFilter = filters.tags.map(tag => tag.toLowerCase()); - return entries.filter(entry => { - if (min != null && entry.amountDollars < min) return false; - if (max != null && entry.amountDollars > max) return false; - const time = new Date(entry.occurredAt).getTime(); - if (from != null && !Number.isNaN(from) && time < from) return false; - if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false; - if (filters.necessity !== "ANY" && entry.necessity !== filters.necessity) return false; - if (query && !(entry.notes || "").toLowerCase().includes(query)) return false; - if (tagsFilter.length) { - const entryTags = (entry.tags || []).map(tag => tag.toLowerCase()); - if (filters.tagsMode === "ALL") { - if (!tagsFilter.every(tag => entryTags.includes(tag))) return false; - } else if (!tagsFilter.some(tag => entryTags.includes(tag))) return false; - } - return true; - }); - }, [entries, filters]); - - const filteredSchedules = useMemo(() => { - const min = filters.amountMin ? Number(filters.amountMin) : null; - const max = filters.amountMax ? Number(filters.amountMax) : null; - const from = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null; - const to = filters.dateTo ? new Date(filters.dateTo).getTime() : null; - const query = filters.notesQuery.trim().toLowerCase(); - const tagsFilter = filters.tags.map(tag => tag.toLowerCase()); - return schedules.filter(schedule => { - if (min != null && schedule.amountDollars < min) return false; - if (max != null && schedule.amountDollars > max) return false; - const time = new Date(schedule.startsOn).getTime(); - if (from != null && !Number.isNaN(from) && time < from) return false; - if (to != null && !Number.isNaN(to) && time > to + 24 * 60 * 60 * 1000 - 1) return false; - if (filters.necessity !== "ANY" && schedule.necessity !== filters.necessity) return false; - if (query && !(schedule.notes || "").toLowerCase().includes(query)) return false; - if (tagsFilter.length) { - const scheduleTags = (schedule.tags || []).map(tag => tag.toLowerCase()); - if (filters.tagsMode === "ALL") { - if (!tagsFilter.every(tag => scheduleTags.includes(tag))) return false; - } else if (!tagsFilter.some(tag => scheduleTags.includes(tag))) return false; - } - return true; - }); - }, [schedules, filters]); - - const visibleEntries = useMemo(() => filteredEntries.slice(0, entryVisibleCount), [filteredEntries, entryVisibleCount]); - const visibleSchedules = useMemo(() => filteredSchedules.slice(0, scheduleVisibleCount), [filteredSchedules, scheduleVisibleCount]); - const hasMoreEntries = filteredEntries.length > visibleEntries.length; - const hasMoreSchedules = filteredSchedules.length > visibleSchedules.length; - - useEffect(() => { - if (entryTab !== "ENTRIES" || !hasMoreEntries) return; - - let touchY: number | null = null; - let lastLoadAt = 0; - let lastScrollY = window.scrollY; - - function shouldLoadMore() { - const sentinel = entriesLoadSentinelRef.current; - if (!sentinel) return false; - const rect = sentinel.getBoundingClientRect(); - return rect.top <= window.innerHeight + 48; - } - - function tryLoadMore() { - if (!shouldLoadMore()) return; - const now = Date.now(); - if (now - lastLoadAt < 150) return; - lastLoadAt = now; - setEntryVisibleCount(prev => { - if (prev >= filteredEntries.length) return prev; - return Math.min(prev + pageSize, filteredEntries.length); - }); - } - - function onWheel(event: WheelEvent) { - if (event.deltaY <= 0) return; - tryLoadMore(); - } - - function onScroll() { - const nextY = window.scrollY; - if (nextY <= lastScrollY) { - lastScrollY = nextY; - return; - } - lastScrollY = nextY; - tryLoadMore(); - } - - function onKeyDown(event: KeyboardEvent) { - if (isEditableTarget(event.target)) return; - if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) { - tryLoadMore(); - } - } - - function onTouchStart(event: TouchEvent) { - touchY = event.touches[0]?.clientY ?? null; - } - - function onTouchMove(event: TouchEvent) { - const nextY = event.touches[0]?.clientY; - if (touchY == null || nextY == null) return; - const delta = touchY - nextY; - touchY = nextY; - if (delta > 10) tryLoadMore(); - } - - window.addEventListener("wheel", onWheel, { passive: true }); - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("keydown", onKeyDown); - window.addEventListener("touchstart", onTouchStart, { passive: true }); - window.addEventListener("touchmove", onTouchMove, { passive: true }); - - return () => { - window.removeEventListener("wheel", onWheel); - window.removeEventListener("scroll", onScroll); - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("touchstart", onTouchStart); - window.removeEventListener("touchmove", onTouchMove); + const listCounts = useMemo(() => { + return { + entriesShown: visibleEntries.length, + entriesTotal: filteredEntries.length, + schedulesShown: visibleSchedules.length, + schedulesTotal: filteredSchedules.length }; - }, [entryTab, hasMoreEntries, filteredEntries.length, pageSize]); - - useEffect(() => { - if (entryTab !== "SCHEDULES" || !hasMoreSchedules) return; - - let touchY: number | null = null; - let lastLoadAt = 0; - let lastScrollY = window.scrollY; - - function shouldLoadMore() { - const sentinel = schedulesLoadSentinelRef.current; - if (!sentinel) return false; - const rect = sentinel.getBoundingClientRect(); - return rect.top <= window.innerHeight + 48; - } - - function tryLoadMore() { - if (!shouldLoadMore()) return; - const now = Date.now(); - if (now - lastLoadAt < 150) return; - lastLoadAt = now; - setScheduleVisibleCount(prev => { - if (prev >= filteredSchedules.length) return prev; - return Math.min(prev + pageSize, filteredSchedules.length); - }); - } - - function onWheel(event: WheelEvent) { - if (event.deltaY <= 0) return; - tryLoadMore(); - } - - function onScroll() { - const nextY = window.scrollY; - if (nextY <= lastScrollY) { - lastScrollY = nextY; - return; - } - lastScrollY = nextY; - tryLoadMore(); - } - - function onKeyDown(event: KeyboardEvent) { - if (isEditableTarget(event.target)) return; - if (event.key === "ArrowDown" || event.key === "PageDown" || event.key === "End" || (event.key === " " && !event.shiftKey)) { - tryLoadMore(); - } - } - - function onTouchStart(event: TouchEvent) { - touchY = event.touches[0]?.clientY ?? null; - } - - function onTouchMove(event: TouchEvent) { - const nextY = event.touches[0]?.clientY; - if (touchY == null || nextY == null) return; - const delta = touchY - nextY; - touchY = nextY; - if (delta > 10) tryLoadMore(); - } - - window.addEventListener("wheel", onWheel, { passive: true }); - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("keydown", onKeyDown); - window.addEventListener("touchstart", onTouchStart, { passive: true }); - window.addEventListener("touchmove", onTouchMove, { passive: true }); - - return () => { - window.removeEventListener("wheel", onWheel); - window.removeEventListener("scroll", onScroll); - window.removeEventListener("keydown", onKeyDown); - window.removeEventListener("touchstart", onTouchStart); - window.removeEventListener("touchmove", onTouchMove); - }; - }, [entryTab, hasMoreSchedules, filteredSchedules.length, pageSize]); - - function clearFilters() { - setFilters(EMPTY_FILTERS); - setEntryVisibleCount(pageSize); - setScheduleVisibleCount(pageSize); - } - - function handleEmptyTagAction() { - if (!canManageTags) return; - router.push("/groups/settings"); - } - - function hasEntryChanges() { - if (!entryDetailsOriginal) return false; - const tags = entryDetailsForm.tags.filter(tag => !entryRemovedTags.includes(tag)); - return ( - entryDetailsForm.amountDollars !== entryDetailsOriginal.amountDollars || - entryDetailsForm.occurredAt !== entryDetailsOriginal.occurredAt || - entryDetailsForm.necessity !== entryDetailsOriginal.necessity || - entryDetailsForm.notes !== entryDetailsOriginal.notes || - entryDetailsForm.entryType !== entryDetailsOriginal.entryType || - normalizeTagList(tags) !== normalizeTagList(entryDetailsOriginal.tags) - ); - } - - function hasScheduleChanges() { - if (!scheduleDetailsOriginal) return false; - const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag)); - return ( - scheduleDetailsForm.amountDollars !== scheduleDetailsOriginal.amountDollars || - scheduleDetailsForm.startsOn !== scheduleDetailsOriginal.startsOn || - scheduleDetailsForm.necessity !== scheduleDetailsOriginal.necessity || - scheduleDetailsForm.notes !== scheduleDetailsOriginal.notes || - scheduleDetailsForm.entryType !== scheduleDetailsOriginal.entryType || - scheduleDetailsForm.frequency !== scheduleDetailsOriginal.frequency || - scheduleDetailsForm.intervalCount !== scheduleDetailsOriginal.intervalCount || - scheduleDetailsForm.endCondition !== scheduleDetailsOriginal.endCondition || - scheduleDetailsForm.endCount !== scheduleDetailsOriginal.endCount || - scheduleDetailsForm.endDate !== scheduleDetailsOriginal.endDate || - scheduleDetailsForm.nextRunOn !== scheduleDetailsOriginal.nextRunOn || - scheduleDetailsForm.isActive !== scheduleDetailsOriginal.isActive || - normalizeTagList(tags) !== normalizeTagList(scheduleDetailsOriginal.tags) - ); - } - - async function submitNewEntry(e: React.FormEvent) { - 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) { - 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) { - 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) { - e.preventDefault(); - if (!selectedScheduleId || !hasScheduleChanges()) return; - const amount = Number(scheduleDetailsForm.amountDollars || 0); - const tags = scheduleDetailsForm.tags.filter(tag => !scheduleRemovedTags.includes(tag)); - if (!Number.isFinite(amount) || amount <= 0 || !tags.length) return; - const updated = await updateSchedule({ - id: selectedScheduleId, - entryType: scheduleDetailsForm.entryType, - amountDollars: amount, - startsOn: scheduleDetailsForm.startsOn, - necessity: scheduleDetailsForm.necessity, - purchaseType: tags.join(", ") || "General", - notes: scheduleDetailsForm.notes.trim() || undefined, - tags, - frequency: scheduleDetailsForm.frequency, - intervalCount: scheduleDetailsForm.intervalCount, - endCondition: scheduleDetailsForm.endCondition, - endCount: scheduleDetailsForm.endCondition === "AFTER_COUNT" ? Number(scheduleDetailsForm.endCount || 0) || null : null, - endDate: scheduleDetailsForm.endCondition === "BY_DATE" ? scheduleDetailsForm.endDate || null : null, - nextRunOn: scheduleDetailsForm.nextRunOn, - isActive: scheduleDetailsForm.isActive - }); - if (!updated) return; - setScheduleDetailsOpen(false); - notify({ title: "Schedule updated", message: `${updated.tags.join(", ")} - $${updated.amountDollars.toFixed(2)}` }); - } - - async function confirmDelete() { - if (deleteTarget === "ENTRY" && selectedEntryId) { - const removed = await deleteEntry(selectedEntryId); - if (!removed) return; - setEntryDetailsOpen(false); - notify({ title: "Entry deleted", message: removed.tags.join(", ") || "Entry removed", tone: "danger" }); - notifyEntryMutation(); - } - if (deleteTarget === "SCHEDULE" && selectedScheduleId) { - const removed = await deleteSchedule(selectedScheduleId); - if (!removed) return; - setScheduleDetailsOpen(false); - notify({ title: "Schedule deleted", message: removed.tags.join(", ") || "Schedule removed", tone: "danger" }); - } - } - - function prevEntry() { - if (!filteredEntries.length) return; - const current = selectedEntryIndex ?? 0; - const index = current === 0 ? filteredEntries.length - 1 : current - 1; - openEntryDetails(filteredEntries[index].id); - } - - function nextEntry() { - if (!filteredEntries.length) return; - const current = selectedEntryIndex ?? 0; - const index = current === filteredEntries.length - 1 ? 0 : current + 1; - openEntryDetails(filteredEntries[index].id); - } + }, [filteredEntries.length, filteredSchedules.length, visibleEntries.length, visibleSchedules.length]); return ( <>
-
- -
- - -
-
+ setFilterOpen(true)} + onOpenCreate={() => { + if (entryTab === "ENTRIES") { + setNewEntryOpen(true); + return; + } + setNewScheduleOpen(true); + }} + /> + {entryTab === "ENTRIES" ? ( <>
- setNewEntryOpen(false)} - onSubmit={submitNewEntry} - onChange={next => setEntryForm(prev => ({ ...prev, ...next }))} - tagSuggestions={tagSuggestions} - emptyTagActionLabel={emptyTagActionLabel} - emptyTagActionDisabled={!canManageTags} - onEmptyTagAction={handleEmptyTagAction} - amountInputRef={amountInputRef} - tagsInputRef={tagsInputRef} - /> - setNewScheduleOpen(false)} - onSubmit={submitNewSchedule} - onChange={next => setScheduleForm(prev => ({ ...prev, ...next }))} - tagSuggestions={tagSuggestions} - emptyTagActionLabel={emptyTagActionLabel} - emptyTagActionDisabled={!canManageTags} - onEmptyTagAction={handleEmptyTagAction} - /> - 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} - /> - 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} - /> - setFilters(prev => (prev.tags.includes(tag) ? prev : { ...prev, tags: [...prev.tags, tag] }))} - onFilterToggleTag={tag => setFilters(prev => ({ ...prev, tags: prev.tags.filter(item => item !== tag) }))} - onClose={() => setFilterOpen(false)} - /> - setConfirmDeleteOpen(false)} - onConfirm={() => { - setConfirmDeleteOpen(false); - confirmDelete(); - }} + handleEmptyTagAction={handleEmptyTagAction} + filterOpen={filterOpen} + setFilterOpen={setFilterOpen} + filters={filters} + setFilters={setFilters} + activeFilterCount={activeFilterCount} + clearFilters={clearFilters} + onFilterAddTag={onFilterAddTag} + onFilterToggleTag={onFilterToggleTag} + newEntryOpen={newEntryOpen} + setNewEntryOpen={setNewEntryOpen} + entryForm={entryForm} + setEntryForm={setEntryForm} + submitNewEntry={submitNewEntry} + amountInputRef={amountInputRef} + tagsInputRef={tagsInputRef} + newScheduleOpen={newScheduleOpen} + setNewScheduleOpen={setNewScheduleOpen} + scheduleForm={scheduleForm} + setScheduleForm={setScheduleForm} + submitNewSchedule={submitNewSchedule} + entryDetailsOpen={entryDetailsOpen} + setEntryDetailsOpen={setEntryDetailsOpen} + entryDetailsForm={entryDetailsForm} + setEntryDetailsForm={setEntryDetailsForm} + entryDetailsOriginal={entryDetailsOriginal} + hasEntryChanges={hasEntryChanges} + submitEntryUpdate={submitEntryUpdate} + entryRemovedTags={entryRemovedTags} + setEntryRemovedTags={setEntryRemovedTags} + prevEntry={prevEntry} + nextEntry={nextEntry} + selectedEntryIndex={selectedEntryIndex} + filteredEntriesCount={filteredEntries.length} + scheduleDetailsOpen={scheduleDetailsOpen} + setScheduleDetailsOpen={setScheduleDetailsOpen} + scheduleDetailsForm={scheduleDetailsForm} + setScheduleDetailsForm={setScheduleDetailsForm} + scheduleDetailsOriginal={scheduleDetailsOriginal} + hasScheduleChanges={hasScheduleChanges} + submitScheduleUpdate={submitScheduleUpdate} + scheduleRemovedTags={scheduleRemovedTags} + setScheduleRemovedTags={setScheduleRemovedTags} + confirmDeleteOpen={confirmDeleteOpen} + setConfirmDeleteOpen={setConfirmDeleteOpen} + deleteTarget={deleteTarget} + setDeleteTarget={setDeleteTarget} + confirmDelete={confirmDelete} /> ); -} +} \ No newline at end of file diff --git a/apps/web/features/entries/components/entries-panel.types.ts b/apps/web/features/entries/components/entries-panel.types.ts new file mode 100644 index 0000000..91e8a55 --- /dev/null +++ b/apps/web/features/entries/components/entries-panel.types.ts @@ -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; diff --git a/apps/web/features/entries/components/entries-panel.utils.ts b/apps/web/features/entries/components/entries-panel.utils.ts new file mode 100644 index 0000000..65c3581 --- /dev/null +++ b/apps/web/features/entries/components/entries-panel.utils.ts @@ -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" + }; +} diff --git a/apps/web/components/entry-details-modal.tsx b/apps/web/features/entries/components/entry-details-modal.tsx similarity index 97% rename from apps/web/components/entry-details-modal.tsx rename to apps/web/features/entries/components/entry-details-modal.tsx index f2e1dd8..f12e5b5 100644 --- a/apps/web/components/entry-details-modal.tsx +++ b/apps/web/features/entries/components/entry-details-modal.tsx @@ -2,14 +2,14 @@ import type React from "react"; import { useEffect, useRef } from "react"; -import TagInput from "@/components/tag-input"; -import ToggleButtonGroup from "@/components/toggle-button-group"; -import DatePicker from "@/components/date-picker"; +import TagInput from "@/shared/components/forms/tag-input"; +import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group"; +import DatePicker from "@/shared/components/forms/date-picker"; export type EntryDetailsForm = { amountDollars: string; occurredAt: string; - necessity: string; + necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; notes: string; tags: string[]; entryType: "SPENDING" | "INCOME"; diff --git a/apps/web/components/new-entry-modal.tsx b/apps/web/features/entries/components/new-entry-modal.tsx similarity index 96% rename from apps/web/components/new-entry-modal.tsx rename to apps/web/features/entries/components/new-entry-modal.tsx index f8c2db7..9e2be31 100644 --- a/apps/web/components/new-entry-modal.tsx +++ b/apps/web/features/entries/components/new-entry-modal.tsx @@ -2,14 +2,14 @@ import type React from "react"; import { useEffect, useRef } from "react"; -import TagInput from "@/components/tag-input"; -import ToggleButtonGroup from "@/components/toggle-button-group"; -import DatePicker from "@/components/date-picker"; +import TagInput from "@/shared/components/forms/tag-input"; +import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group"; +import DatePicker from "@/shared/components/forms/date-picker"; type NewEntryForm = { amountDollars: string; occurredAt: string; - necessity: string; + necessity: "NECESSARY" | "BOTH" | "UNNECESSARY"; notes: string; tags: string[]; entryType: "SPENDING" | "INCOME"; diff --git a/apps/web/components/new-schedule-modal.tsx b/apps/web/features/entries/components/new-schedule-modal.tsx similarity index 98% rename from apps/web/components/new-schedule-modal.tsx rename to apps/web/features/entries/components/new-schedule-modal.tsx index 37f6ebb..0b2d910 100644 --- a/apps/web/components/new-schedule-modal.tsx +++ b/apps/web/features/entries/components/new-schedule-modal.tsx @@ -2,9 +2,9 @@ import type React from "react"; import { useEffect, useRef } from "react"; -import DatePicker from "@/components/date-picker"; -import TagInput from "@/components/tag-input"; -import ToggleButtonGroup from "@/components/toggle-button-group"; +import DatePicker from "@/shared/components/forms/date-picker"; +import TagInput from "@/shared/components/forms/tag-input"; +import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group"; import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types"; export type NewScheduleForm = { diff --git a/apps/web/components/schedule-details-modal.tsx b/apps/web/features/entries/components/schedule-details-modal.tsx similarity index 98% rename from apps/web/components/schedule-details-modal.tsx rename to apps/web/features/entries/components/schedule-details-modal.tsx index 9e3fe2b..8892dd3 100644 --- a/apps/web/components/schedule-details-modal.tsx +++ b/apps/web/features/entries/components/schedule-details-modal.tsx @@ -2,9 +2,9 @@ import type React from "react"; import { useEffect, useRef } from "react"; -import DatePicker from "@/components/date-picker"; -import TagInput from "@/components/tag-input"; -import ToggleButtonGroup from "@/components/toggle-button-group"; +import DatePicker from "@/shared/components/forms/date-picker"; +import TagInput from "@/shared/components/forms/tag-input"; +import ToggleButtonGroup from "@/shared/components/forms/toggle-button-group"; import type { ScheduleEndCondition, ScheduleFrequency } from "@/lib/shared/types"; export type ScheduleDetailsForm = { diff --git a/apps/web/features/entries/components/use-entries-panel-crud.ts b/apps/web/features/entries/components/use-entries-panel-crud.ts new file mode 100644 index 0000000..d673449 --- /dev/null +++ b/apps/web/features/entries/components/use-entries-panel-crud.ts @@ -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; + updateEntry: (input: UpdateEntryInput) => Promise; + deleteEntry: (id: number | string) => Promise; + createSchedule: (input: ScheduleInput & { createEntryNow?: boolean }) => Promise; + updateSchedule: (input: ScheduleInput & { id: number; nextRunOn?: string; isActive?: boolean }) => Promise; + deleteSchedule: (id: number | string) => Promise; + 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("ENTRY"); + + const [selectedEntryId, setSelectedEntryId] = useState(null); + const [selectedEntryIndex, setSelectedEntryIndex] = useState(null); + const [selectedScheduleId, setSelectedScheduleId] = useState(null); + + const [entryRemovedTags, setEntryRemovedTags] = useState([]); + const [scheduleRemovedTags, setScheduleRemovedTags] = useState([]); + + const [entryForm, setEntryForm] = useState(() => createInitialEntryForm(today)); + const [entryDetailsForm, setEntryDetailsForm] = useState(() => createInitialEntryForm(today)); + const [entryDetailsOriginal, setEntryDetailsOriginal] = useState(null); + const [scheduleForm, setScheduleForm] = useState(() => createInitialScheduleForm(today)); + const [scheduleDetailsForm, setScheduleDetailsForm] = useState(() => createInitialScheduleDetailsForm(today)); + const [scheduleDetailsOriginal, setScheduleDetailsOriginal] = useState(null); + + const amountInputRef = useRef(null); + const tagsInputRef = useRef(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) { + 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) { + 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) { + 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) { + 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; diff --git a/apps/web/features/entries/components/use-entries-panel-filters.ts b/apps/web/features/entries/components/use-entries-panel-filters.ts new file mode 100644 index 0000000..044ad45 --- /dev/null +++ b/apps/web/features/entries/components/use-entries-panel-filters.ts @@ -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("ENTRIES"); + const [filters, setFilters] = useState(() => createEmptyEntriesFilters()); + const [entryVisibleCount, setEntryVisibleCount] = useState(pageSize); + const [scheduleVisibleCount, setScheduleVisibleCount] = useState(pageSize); + const [filterOpen, setFilterOpen] = useState(false); + + const entriesLoadSentinelRef = useRef(null); + const schedulesLoadSentinelRef = useRef(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; diff --git a/apps/web/features/entries/components/use-infinite-visible-count.ts b/apps/web/features/entries/components/use-infinite-visible-count.ts new file mode 100644 index 0000000..a5fd935 --- /dev/null +++ b/apps/web/features/entries/components/use-infinite-visible-count.ts @@ -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; + setVisibleCount: Dispatch>; +}; + +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]); +} diff --git a/apps/web/components/group-dropdown.tsx b/apps/web/features/groups/components/group-dropdown.tsx similarity index 100% rename from apps/web/components/group-dropdown.tsx rename to apps/web/features/groups/components/group-dropdown.tsx diff --git a/apps/web/features/groups/components/group-settings-audit-card.tsx b/apps/web/features/groups/components/group-settings-audit-card.tsx new file mode 100644 index 0000000..738902c --- /dev/null +++ b/apps/web/features/groups/components/group-settings-audit-card.tsx @@ -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 ( +
+
+
Audit log
+ +
+ {vm.auditOpen ? ( + <> +
+
+
Total logs
+
{vm.events.length}
+
+
+
Logs today
+
{vm.logsToday}
+
+ +
+
+ {([ + { key: "entries", label: "Entries" }, + { key: "members", label: "Members" }, + { key: "tags", label: "Tags" }, + { key: "settings", label: "Settings" } + ] as const).map(filter => ( + + ))} +
+
+
+ + + + + vm.setAuditQuery(event.target.value)} + /> +
+
+
+ + + + + + + vm.setAuditFrom(event.target.value)} + /> +
+
+ + + + + + + vm.setAuditTo(event.target.value)} + /> +
+
+
+ {!vm.filteredEvents.length ? ( +
No audit events match your filters.
+ ) : ( +
+ {vm.filteredEvents.slice(0, vm.auditLimit).map(event => ( +
+
{event.eventType}
+
+ Actor: {vm.getMemberLabel(event.actorUserId)} + Role: {event.actorRole ?? "-"} + {new Date(event.createdAt).toLocaleString()} +
+
+ ))} + {vm.filteredEvents.length > vm.auditLimit ? ( + + ) : null} +
+ )} + + ) : ( +
Audit log is collapsed.
+ )} +
+ ); +} diff --git a/apps/web/features/groups/components/group-settings-content.tsx b/apps/web/features/groups/components/group-settings-content.tsx new file mode 100644 index 0000000..2e3ffa6 --- /dev/null +++ b/apps/web/features/groups/components/group-settings-content.tsx @@ -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 ( +
+
Loading group settings...
+
+ ); + } + + return ( + <> +
+
+
+

{vm.group.name} settings

+

Manage tags and permissions for this group.

+
+ +
+ + + + + + +
+ + + + ); +} \ No newline at end of file diff --git a/apps/web/features/groups/components/group-settings-danger-card.tsx b/apps/web/features/groups/components/group-settings-danger-card.tsx new file mode 100644 index 0000000..6b4d5fd --- /dev/null +++ b/apps/web/features/groups/components/group-settings-danger-card.tsx @@ -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 ( +
+
+
Danger zone
+
+
+ {vm.showLeaveGroup ? ( + + ) : null} + +
+ {!vm.isOwner ?
Only the owner can delete this group.
: null} +
+ ); +} diff --git a/apps/web/features/groups/components/group-settings-general-card.tsx b/apps/web/features/groups/components/group-settings-general-card.tsx new file mode 100644 index 0000000..4303d68 --- /dev/null +++ b/apps/web/features/groups/components/group-settings-general-card.tsx @@ -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 ( +
+
+
General Settings
+
+
+
+
+
Group name
+
{vm.group.name}
+
+ {vm.isAdmin ? ( + + ) : null} +
+ {!vm.isAdmin ? ( +
Only admins can rename the group.
+ ) : null} +
+ {vm.isAdmin ? ( + <> +
+
Allow members to manage tags
+ +
+
+ + ) : null} +
+
Group Tags ({vm.tags.length})
+
+ {vm.showAllTags ? ( + + ) : null} + {vm.canManageTags ? ( + + ) : null} +
+
+
+
+ {vm.visibleTags.map(tag => ( + + #{tag} + + ))} + {!vm.showAllTags && vm.hasMoreTags ? ( + + ) : null} + {!vm.tags.length ?
No tags yet.
: null} +
+
+
+
+ ); +} diff --git a/apps/web/features/groups/components/group-settings-join-invites-card.tsx b/apps/web/features/groups/components/group-settings-join-invites-card.tsx new file mode 100644 index 0000000..bb095c4 --- /dev/null +++ b/apps/web/features/groups/components/group-settings-join-invites-card.tsx @@ -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 ( +
+
+
Join and Invites
+
+
+
+
Join policy
+ +
+
+
+
+
Join Requests ({vm.requests.length})
+ {!vm.requests.length ? ( +
No pending requests.
+ ) : ( +
+ {vm.requests.map(request => ( +
+
+
{request.displayName || request.email}
+
{request.email}
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+
Invite links
+
+ + + +
+ {!vm.links.length ? ( +
No invite links yet.
+ ) : ( +
+ {vm.links.map(link => ( +
+
+
Token: {link.token.slice(0, 6)}...{link.token.slice(-4)}
+ {(() => { + 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 ( + + ); + })()} +
+
+ Expires {vm.formatInviteExpiry(link.expiresAt)} + Uses: {link.singleUse ? "1 use" : "Unlimited"} + Status: {link.revokedAt ? "Revoked" : link.singleUse && link.usedAt ? "Used" : vm.isInviteExpired(link.expiresAt) ? "Expired" : "Active"} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/web/features/groups/components/group-settings-members-card.tsx b/apps/web/features/groups/components/group-settings-members-card.tsx new file mode 100644 index 0000000..a1d4716 --- /dev/null +++ b/apps/web/features/groups/components/group-settings-members-card.tsx @@ -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 ( +
+
+
Members
+
{vm.memberCount} total
+
+
+ {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 ( +
+
+
+ {member.displayName || member.email} + {isSelf ? You : null} +
+
{member.email}
+
{privilegeLabel}
+
+
+ {vm.isAdmin ? ( + + ) : ( + {member.role} + )} + {vm.isAdmin && member.role !== "GROUP_OWNER" ? ( + + ) : null} + {vm.isOwner && member.role !== "GROUP_OWNER" ? ( + + ) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/features/groups/components/group-settings-modals.tsx b/apps/web/features/groups/components/group-settings-modals.tsx new file mode 100644 index 0000000..2f2654c --- /dev/null +++ b/apps/web/features/groups/components/group-settings-modals.tsx @@ -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 ? ( +
+
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} + > + +
Change group name
+ vm.setRenameValue(event.target.value)} + placeholder="Group name" + /> + {vm.renameDirty ? ( +
+ You have unsaved changes. +
+ ) : null} +
+ {vm.renameDirty ? ( + <> + + + + ) : ( + + )} +
+
+
+ ) : null} + + {vm.tagModalOpen ? ( +
vm.setTagModalOpen(false)}> +
event.stopPropagation()} + onKeyDown={event => { + if (event.key === "Escape") vm.setTagModalOpen(false); + }} + role="dialog" + tabIndex={-1} + > +
+
Edit tags
+ +
+
+ vm.setPendingTags(prev => prev.filter(item => item !== tag))} + onAddTag={tag => vm.setPendingTags(prev => (prev.includes(tag) ? prev : [...prev, tag]))} + /> + + {!vm.canManageTags ? ( +
Only admins can add new tags.
+ ) : null} +
+
Existing tags
+
+
+ {vm.tags.map(tag => ( + + ))} + {!vm.tags.length ?
No tags yet.
: null} +
+
+ {vm.toggleRemoveTags.length ? ( + + ) : null} +
+
+
+ ) : null} + + vm.setConfirmDeleteOpen(false)} + onConfirm={() => { + vm.setConfirmDeleteOpen(false); + vm.handleConfirmDelete(); + }} + /> + vm.setConfirmDeleteInvite(null)} + onConfirm={vm.handleConfirmDeleteInvite} + /> + vm.setConfirmRenameOpen(false)} + onConfirm={vm.handleRenameGroup} + /> + vm.setConfirmLeaveOpen(false)} + onConfirm={vm.handleConfirmLeaveGroup} + /> + vm.setConfirmKick(null)} + onConfirm={vm.handleConfirmKickMember} + /> + vm.setConfirmTransfer(null)} + onConfirm={vm.handleConfirmTransferOwnership} + /> + vm.setConfirmDeleteGroupOpen(false)} + onConfirm={vm.handleDeleteGroup} + /> + + ); +} diff --git a/apps/web/features/groups/components/group-settings.types.ts b/apps/web/features/groups/components/group-settings.types.ts new file mode 100644 index 0000000..a45924c --- /dev/null +++ b/apps/web/features/groups/components/group-settings.types.ts @@ -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; +}; diff --git a/apps/web/features/groups/components/group-settings.utils.ts b/apps/web/features/groups/components/group-settings.utils.ts new file mode 100644 index 0000000..0c6f52b --- /dev/null +++ b/apps/web/features/groups/components/group-settings.utils.ts @@ -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"; +} diff --git a/apps/web/features/groups/components/use-group-settings-view-model.ts b/apps/web/features/groups/components/use-group-settings-view-model.ts new file mode 100644 index 0000000..b7e2b4f --- /dev/null +++ b/apps/web/features/groups/components/use-group-settings-view-model.ts @@ -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([]); + const [toggleRemoveTags, setToggleRemoveTags] = useState([]); + 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(null); + const [confirmTransfer, setConfirmTransfer] = useState(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("UNLIMITED"); + const [auditOpen, setAuditOpen] = useState(false); + const [confirmDeleteInvite, setConfirmDeleteInvite] = useState(null); + const [auditFilters, setAuditFilters] = useState(["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(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(); + 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; diff --git a/apps/web/components/settings-content.tsx b/apps/web/features/user-settings/components/settings-content.tsx similarity index 100% rename from apps/web/components/settings-content.tsx rename to apps/web/features/user-settings/components/settings-content.tsx diff --git a/apps/web/hooks/notifications-context.tsx b/apps/web/hooks/notifications-context.tsx index b69067b..8282100 100644 --- a/apps/web/hooks/notifications-context.tsx +++ b/apps/web/hooks/notifications-context.tsx @@ -1,7 +1,7 @@ "use client"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; -import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/components/notifications-toaster"; +import NotificationsToaster, { type NotificationItem, type NotificationTone } from "@/shared/components/feedback/notifications-toaster"; type NotifyInput = { title: string; diff --git a/apps/web/shared/README.md b/apps/web/shared/README.md index 3ac439f..ed92341 100644 --- a/apps/web/shared/README.md +++ b/apps/web/shared/README.md @@ -2,4 +2,9 @@ Cross-domain reusable primitives only. +Current structure: +- `shared/components/forms`: generic form controls (`date-picker`, `tag-input`, `toggle-button-group`) +- `shared/components/modals`: reusable confirmation modals +- `shared/components/feedback`: global feedback UI (`notifications-toaster`) + Use this for generic components/hooks/lib that are not tied to a single business domain. diff --git a/apps/web/components/notifications-toaster.tsx b/apps/web/shared/components/feedback/notifications-toaster.tsx similarity index 100% rename from apps/web/components/notifications-toaster.tsx rename to apps/web/shared/components/feedback/notifications-toaster.tsx diff --git a/apps/web/components/date-picker.tsx b/apps/web/shared/components/forms/date-picker.tsx similarity index 100% rename from apps/web/components/date-picker.tsx rename to apps/web/shared/components/forms/date-picker.tsx diff --git a/apps/web/components/tag-input.tsx b/apps/web/shared/components/forms/tag-input.tsx similarity index 100% rename from apps/web/components/tag-input.tsx rename to apps/web/shared/components/forms/tag-input.tsx diff --git a/apps/web/components/toggle-button-group.tsx b/apps/web/shared/components/forms/toggle-button-group.tsx similarity index 100% rename from apps/web/components/toggle-button-group.tsx rename to apps/web/shared/components/forms/toggle-button-group.tsx diff --git a/apps/web/components/confirm-retype-modal.tsx b/apps/web/shared/components/modals/confirm-retype-modal.tsx similarity index 100% rename from apps/web/components/confirm-retype-modal.tsx rename to apps/web/shared/components/modals/confirm-retype-modal.tsx diff --git a/apps/web/components/confirm-slide-modal.tsx b/apps/web/shared/components/modals/confirm-slide-modal.tsx similarity index 100% rename from apps/web/components/confirm-slide-modal.tsx rename to apps/web/shared/components/modals/confirm-slide-modal.tsx diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 69df9e0..26839d7 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -3,7 +3,6 @@ import type { Config } from "tailwindcss"; export default { content: [ "./app/**/*.{ts,tsx}", - "./components/**/*.{ts,tsx}", "./features/**/*.{ts,tsx}", "./shared/**/*.{ts,tsx}" ], diff --git a/docs/05_REFACTOR_2.md b/docs/05_REFACTOR_2.md index c8aff9c..b93b7cb 100644 --- a/docs/05_REFACTOR_2.md +++ b/docs/05_REFACTOR_2.md @@ -152,3 +152,6 @@ Primary outcomes: ### Risks / Notes to Revisit - Workspace is intentionally dirty; commits must be path-scoped to avoid mixing unrelated changes. - This Codex session currently cannot write to `.git` (index lock permission denied), so local user-side commits are required for newly staged changes. +- Added first-time operator bootstrap runbook for fresh Ubuntu VM + Docker + Dokploy: + - `docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md` + - Includes command-by-command install, verification checkpoints, hardening baseline, Gitea/Dokploy wiring order, and an execution log template for audit/history. diff --git a/docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md b/docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md new file mode 100644 index 0000000..073d209 --- /dev/null +++ b/docs/12_DOKPLOY_VM_BOOTSTRAP_VERBOSE.md @@ -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 @ +``` + +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 +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://: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://: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.