Commit 1776aa39 authored by ThinhNC's avatar ThinhNC

merge: merge feat/shared-wallets-and-family-budget-ui into develop

parents 92621450 d35a44ac
Pipeline #53728 passed with stage
in 48 seconds
import React, { useState } from "react";
import { Modal } from "@/components/ui/Modal";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { CopyIcon, CheckIcon, QrCodeIcon, UsersIcon } from "@/components/ui/icons";
import { WalletMemberRole, WalletInvitation } from "@/types/shared-wallet";
import { useCreateInvitation } from "@/hooks/use-shared-wallets";
import { useI18n } from "@/i18n";
interface InviteMemberModalProps {
isOpen: boolean;
onClose: () => void;
walletId: string;
walletName: string;
}
export const InviteMemberModal: React.FC<InviteMemberModalProps> = ({
isOpen,
onClose,
walletId,
walletName,
}) => {
const { t } = useI18n();
const [role, setRole] = useState<WalletMemberRole>("MEMBER");
const [expiresInDays, setExpiresInDays] = useState<number>(7);
const [invitation, setInvitation] = useState<WalletInvitation | null>(null);
const [copied, setCopied] = useState(false);
const createMutation = useCreateInvitation(walletId);
const handleGenerate = async () => {
try {
const res = await createMutation.mutateAsync({
role,
expiresInDays,
});
if (res.data) {
setInvitation(res.data);
}
} catch (err) {
console.error("Failed to create invitation:", err);
}
};
const handleCopy = () => {
if (!invitation?.inviteCode) return;
navigator.clipboard.writeText(invitation.inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleClose = () => {
setInvitation(null);
setCopied(false);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={t("sharedWallet.inviteModal.title", { name: walletName })}
>
<div className="flex flex-col gap-4">
{!invitation ? (
<>
<p className="text-sm text-clay-text-muted">
{t("sharedWallet.inviteModal.description")}
</p>
<div className="flex flex-col gap-2">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.inviteModal.roleLabel")}
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setRole("MEMBER")}
className={`flex flex-col items-center gap-1.5 p-3 rounded-clay-sm border text-left transition-all ${
role === "MEMBER"
? "bg-clay-primary/10 border-clay-primary text-clay-primary shadow-clay-pressed font-bold"
: "bg-clay-surface border-clay-border text-clay-text hover:bg-clay-bg"
}`}
>
<UsersIcon size={20} className={role === "MEMBER" ? "text-clay-primary" : "text-clay-text-muted"} />
<span className="text-sm">{t("sharedWallet.inviteModal.roleMember")}</span>
<span className="text-[11px] text-clay-text-muted text-center font-normal">
{t("sharedWallet.inviteModal.roleMemberDesc")}
</span>
</button>
<button
type="button"
onClick={() => setRole("VIEWER")}
className={`flex flex-col items-center gap-1.5 p-3 rounded-clay-sm border text-left transition-all ${
role === "VIEWER"
? "bg-clay-warning/10 border-clay-warning text-clay-warning shadow-clay-pressed font-bold"
: "bg-clay-surface border-clay-border text-clay-text hover:bg-clay-bg"
}`}
>
<QrCodeIcon size={20} className={role === "VIEWER" ? "text-clay-warning" : "text-clay-text-muted"} />
<span className="text-sm">{t("sharedWallet.inviteModal.roleViewer")}</span>
<span className="text-[11px] text-clay-text-muted text-center font-normal">
{t("sharedWallet.inviteModal.roleViewerDesc")}
</span>
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.inviteModal.expiryLabel")}
</label>
<div className="flex gap-2">
{[
{ days: 1, label: t("sharedWallet.inviteModal.expiry1Day") },
{ days: 7, label: t("sharedWallet.inviteModal.expiry7Days") },
{ days: 30, label: t("sharedWallet.inviteModal.expiry30Days") },
].map((item) => (
<button
key={item.days}
type="button"
onClick={() => setExpiresInDays(item.days)}
className={`flex-1 py-2 text-xs font-bold rounded-clay-sm border transition-all ${
expiresInDays === item.days
? "bg-clay-surface border-clay-primary text-clay-primary shadow-clay-pressed"
: "bg-clay-surface border-clay-border text-clay-text-muted hover:text-clay-text"
}`}
>
{item.label}
</button>
))}
</div>
</div>
<Button
variant="primary"
onClick={handleGenerate}
disabled={createMutation.isPending}
className="w-full mt-2"
>
{createMutation.isPending
? t("sharedWallet.inviteModal.creating")
: t("sharedWallet.inviteModal.createBtn")}
</Button>
</>
) : (
<div className="flex flex-col items-center text-center gap-4">
<div className="p-3 bg-white rounded-clay border border-clay-border shadow-clay-raised">
<img
src={invitation.qrCodeDataUrl}
alt={t("sharedWallet.inviteModal.qrAlt")}
className="w-48 h-48 object-contain rounded-lg"
/>
</div>
<div className="w-full">
<span className="text-xs text-clay-text-muted uppercase tracking-wider font-bold">
{t("sharedWallet.inviteModal.inviteCodeLabel")}
</span>
<div className="flex items-center justify-between gap-2 mt-1.5 p-3 rounded-clay-sm bg-clay-bg border border-clay-highlight shadow-clay-pressed">
<span className="font-mono text-xl font-bold tracking-widest text-clay-primary select-all">
{invitation.inviteCode}
</span>
<Button
variant={copied ? "primary" : "secondary"}
onClick={handleCopy}
className="flex items-center gap-1.5 !py-1.5 !px-3"
>
{copied ? (
<>
<CheckIcon size={16} />
<span>{t("sharedWallet.inviteModal.copied")}</span>
</>
) : (
<>
<CopyIcon size={16} />
<span>{t("sharedWallet.inviteModal.copy")}</span>
</>
)}
</Button>
</div>
</div>
<Card className="w-full text-left bg-clay-bg/60 border border-clay-border/60 !p-3">
<p className="text-xs text-clay-text-muted leading-relaxed">
💡 <span className="font-bold text-clay-text">{t("sharedWallet.inviteModal.guideTitle")}</span> {t("sharedWallet.inviteModal.guideDesc")}
</p>
</Card>
<Button
variant="secondary"
onClick={handleClose}
className="w-full"
>
{t("sharedWallet.inviteModal.done")}
</Button>
</div>
)}
</div>
</Modal>
);
};
import React, { useState, useEffect } from "react";
import { scanQRCode } from "zmp-sdk/apis";
import { Modal } from "@/components/ui/Modal";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { SharedWalletBadge } from "./SharedWalletBadge";
import { UsersIcon, QrCodeIcon } from "@/components/ui/icons";
import { useAcceptInvitation, useInvitationPreview } from "@/hooks/use-shared-wallets";
import { useI18n } from "@/i18n";
interface JoinWalletModalProps {
isOpen: boolean;
onClose: () => void;
initialCode?: string;
onSuccess?: (walletId: string) => void;
}
export const parseInviteCode = (content: string): string => {
if (!content) return "";
const trimmed = content.trim();
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && parsed.code) {
return String(parsed.code).trim().toUpperCase();
}
} catch {
// Not JSON
}
if (trimmed.includes("code=")) {
const match = trimmed.match(/code=([A-Za-z0-9-]+)/);
if (match && match[1]) {
return match[1].trim().toUpperCase();
}
}
const fwMatch = trimmed.match(/FW-[A-Za-z0-9]{5,10}/i);
if (fwMatch) {
return fwMatch[0].toUpperCase();
}
return trimmed.toUpperCase();
};
export const JoinWalletModal: React.FC<JoinWalletModalProps> = ({
isOpen,
onClose,
initialCode = "",
onSuccess,
}) => {
const { t } = useI18n();
const [code, setCode] = useState(initialCode);
const [nickname, setNickname] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
useEffect(() => {
if (initialCode) {
setCode(initialCode);
}
}, [initialCode]);
const cleanCode = code.trim().toUpperCase();
const { data: previewRes, isLoading: isPreviewLoading, error: previewError } =
useInvitationPreview(cleanCode, cleanCode.length >= 4);
const acceptMutation = useAcceptInvitation();
const preview = previewRes?.data;
const handleScanQR = async () => {
setErrorMsg(null);
setIsScanning(true);
try {
const res = await scanQRCode({});
const scanRes = res as unknown as { content?: string; data?: string } | undefined;
const scannedContent = scanRes?.content || scanRes?.data || "";
if (!scannedContent) {
setErrorMsg(t("sharedWallet.joinModal.noQrContent"));
return;
}
const extractedCode = parseInviteCode(scannedContent);
if (extractedCode) {
setCode(extractedCode);
} else {
setErrorMsg(t("sharedWallet.joinModal.invalidQrFormat"));
}
} catch (err) {
console.warn("Scan QR failed or cancelled:", err);
setErrorMsg(t("sharedWallet.joinModal.cameraError"));
} finally {
setIsScanning(false);
}
};
const handleJoin = async () => {
if (!cleanCode) return;
setErrorMsg(null);
try {
const res = await acceptMutation.mutateAsync({
inviteCode: cleanCode,
nickname: nickname.trim() || undefined,
});
if (res.data) {
onClose();
setCode("");
setNickname("");
if (onSuccess && preview?.walletId) {
onSuccess(preview.walletId);
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t("sharedWallet.joinModal.joinFailed");
setErrorMsg(message);
}
};
const handleClose = () => {
setCode("");
setNickname("");
setErrorMsg(null);
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={t("sharedWallet.joinModal.title")}
>
<div className="flex flex-col gap-4">
{/* Prominent Scan QR Button */}
<Button
type="button"
variant="secondary"
onClick={handleScanQR}
disabled={isScanning}
className="w-full flex items-center justify-center gap-2.5 !py-3 !bg-clay-primary/10 border-2 border-clay-primary/30 text-clay-primary hover:!bg-clay-primary/20 shadow-clay-raised"
>
<QrCodeIcon size={22} className="text-clay-primary" />
<span className="font-bold text-sm">
{isScanning
? t("sharedWallet.joinModal.openingCamera")
: t("sharedWallet.joinModal.scanCamera")}
</span>
</Button>
<div className="flex items-center gap-3">
<div className="flex-1 h-[1px] bg-clay-border/80" />
<span className="text-[11px] text-clay-text-muted font-bold uppercase tracking-wider">
{t("sharedWallet.joinModal.orManual")}
</span>
<div className="flex-1 h-[1px] bg-clay-border/80" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.joinModal.codeLabel")}
</label>
<Input
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase());
setErrorMsg(null);
}}
placeholder={t("sharedWallet.joinModal.codePlaceholder")}
className="font-mono text-center tracking-widest uppercase font-bold text-lg"
/>
</div>
{/* Preview Card */}
{isPreviewLoading && cleanCode.length >= 4 && (
<div className="p-4 rounded-clay bg-clay-bg/60 border border-clay-border animate-pulse flex items-center justify-center text-sm text-clay-text-muted">
{t("sharedWallet.joinModal.checking")}
</div>
)}
{preview && (
<Card className="flex flex-col gap-3 !p-4 bg-clay-primary/5 border border-clay-primary/20">
<div className="flex items-start justify-between">
<div>
<span className="text-xs text-clay-text-muted">{t("sharedWallet.joinModal.invitedTo")}</span>
<h4 className="font-baloo text-lg font-bold text-clay-text">
{preview.walletName}
</h4>
</div>
<SharedWalletBadge role={preview.role} />
</div>
<div className="flex items-center gap-2.5 pt-2 border-t border-clay-border/40">
<Avatar
src={preview.inviterAvatar || undefined}
alt={preview.inviterName}
size="sm"
/>
<div className="flex flex-col">
<span className="text-xs text-clay-text-muted">{t("sharedWallet.joinModal.inviter")}</span>
<span className="text-sm font-bold text-clay-text">
{preview.inviterName}
</span>
</div>
<div className="ml-auto flex items-center gap-1 text-xs text-clay-text-muted">
<UsersIcon size={14} />
<span>{t("sharedWallet.joinModal.memberCount", { count: preview.activeMemberCount })}</span>
</div>
</div>
</Card>
)}
{previewError && cleanCode.length >= 4 && (
<div className="p-3 rounded-clay-sm bg-clay-expense/10 border border-clay-expense/30 text-xs text-clay-expense font-semibold text-center">
{t("sharedWallet.joinModal.invalidOrExpired")}
</div>
)}
{errorMsg && (
<div className="p-3 rounded-clay-sm bg-clay-expense/10 border border-clay-expense/30 text-xs text-clay-expense font-semibold text-center">
{errorMsg}
</div>
)}
{preview && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.joinModal.nicknameLabel")}
</label>
<Input
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={t("sharedWallet.joinModal.nicknamePlaceholder")}
maxLength={50}
/>
</div>
)}
<Button
variant="primary"
onClick={handleJoin}
disabled={!preview || acceptMutation.isPending}
className="w-full mt-2"
>
{acceptMutation.isPending
? t("sharedWallet.joinModal.joining")
: t("sharedWallet.joinModal.joinBtn")}
</Button>
</div>
</Modal>
);
};
import React from "react";
import { Avatar } from "@/components/ui/Avatar";
import { useI18n } from "@/i18n";
interface MemberAvatarItem {
id?: string;
name?: string | null;
avatarUrl?: string | null;
}
interface MemberAvatarStackProps {
members: MemberAvatarItem[];
max?: number;
size?: "sm" | "md";
className?: string;
}
export const MemberAvatarStack: React.FC<MemberAvatarStackProps> = ({
members,
max = 3,
size = "sm",
className = "",
}) => {
const { t } = useI18n();
if (!members || members.length === 0) return null;
const visibleMembers = members.slice(0, max);
const remainingCount = members.length - max;
const defaultMember = t("sharedWallet.analytics.defaultMemberName");
const sizeClasses = {
sm: "w-7 h-7 -ml-2 text-[10px]",
md: "w-9 h-9 -ml-3 text-xs",
};
return (
<div className={`flex items-center pl-2 ${className}`}>
{visibleMembers.map((member, index) => (
<div
key={member.id || index}
className="relative inline-block transition-transform hover:scale-110 hover:z-10"
title={member.name || defaultMember}
>
<Avatar
src={member.avatarUrl || undefined}
alt={member.name || defaultMember}
size="sm"
className={`${size === "sm" ? "!w-7 !h-7 !border-[1.5px]" : "!w-9 !h-9 !border-2"} !border-clay-surface ring-2 ring-clay-highlight/50 shadow-clay-raised -ml-2 first:ml-0`}
/>
</div>
))}
{remainingCount > 0 && (
<div
className={`flex items-center justify-center font-nunito font-bold rounded-full bg-clay-surface border border-clay-highlight text-clay-text-muted shadow-clay-raised z-0 ${sizeClasses[size]}`}
title={t("sharedWallet.card.moreMembers", { count: remainingCount })}
>
+{remainingCount}
</div>
)}
</div>
);
};
import React from "react";
import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { SharedWalletBadge } from "./SharedWalletBadge";
import { useSharedWalletAnalytics } from "@/hooks/use-shared-wallets";
import { useI18n } from "@/i18n";
interface SharedWalletAnalyticsCardProps {
walletId: string;
currency: string;
}
export const SharedWalletAnalyticsCard: React.FC<SharedWalletAnalyticsCardProps> = ({
walletId,
currency,
}) => {
const { t } = useI18n();
const { data: analyticsRes, isLoading } = useSharedWalletAnalytics(walletId);
if (isLoading) {
return (
<div className="h-64 rounded-clay bg-clay-bg/60 border border-clay-border animate-pulse" />
);
}
const analytics = analyticsRes?.data;
if (!analytics) return null;
const totalExpenseNum = Number(analytics.totalSpent || analytics.totalExpense || 0);
const totalIncomeNum = Number(analytics.totalIncome || 0);
return (
<div className="flex flex-col gap-4">
{/* Top Overview Cards */}
<div className="grid grid-cols-2 gap-3">
<Card className="flex flex-col gap-1 !p-3.5 bg-clay-expense/5 border border-clay-expense/20">
<span className="text-xs text-clay-text-muted font-bold">
{t("sharedWallet.analytics.totalSpent")}
</span>
<span className="font-baloo text-lg font-bold text-clay-expense">
{totalExpenseNum.toLocaleString("vi-VN")} {currency}
</span>
</Card>
<Card className="flex flex-col gap-1 !p-3.5 bg-clay-income/5 border border-clay-income/20">
<span className="text-xs text-clay-text-muted font-bold">
{t("sharedWallet.analytics.totalInjected")}
</span>
<span className="font-baloo text-lg font-bold text-clay-income">
{totalIncomeNum.toLocaleString("vi-VN")} {currency}
</span>
</Card>
</div>
{/* Member Spending Breakdown */}
<Card className="flex flex-col gap-3.5 !p-4 border border-clay-border/80 shadow-clay-raised">
<div className="flex items-center justify-between">
<h4 className="font-baloo text-base font-bold text-clay-text">
{t("sharedWallet.analytics.breakdownTitle")}
</h4>
<span className="text-xs text-clay-text-muted">
{t("sharedWallet.analytics.memberCount", { count: analytics.memberBreakdown?.length || 0 })}
</span>
</div>
<div className="flex flex-col gap-4">
{analytics.memberBreakdown?.map((member) => {
const spentNum = Number(member.spentAmount || member.totalSpent || 0);
const percent = Number(member.spendingPercentage || member.percentage || 0);
return (
<div key={member.userId} className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Avatar
src={member.avatarUrl || undefined}
alt={member.fullName || "Member"}
size="sm"
className="!w-6 !h-6"
/>
<span className="font-bold text-clay-text">
{member.nickname || member.fullName || t("sharedWallet.analytics.defaultMemberName")}
</span>
<SharedWalletBadge role={member.role} showIcon={false} className="!text-[10px] !py-0 !px-1.5" />
</div>
<div className="flex items-center gap-2">
<span className="font-bold text-clay-text">
{spentNum.toLocaleString("vi-VN")} {currency}
</span>
<span className="text-clay-text-muted font-semibold w-10 text-right">
{percent.toFixed(1)}%
</span>
</div>
</div>
<ProgressBar
value={percent}
type={percent > 50 ? "expense" : percent > 25 ? "warning" : "primary"}
/>
</div>
);
})}
</div>
</Card>
{/* Settlement Summary (Đối soát đóng góp & chi tiêu) */}
{analytics.settlementSummary && analytics.settlementSummary.length > 0 && (
<Card className="flex flex-col gap-3 !p-4 border border-clay-border/80 shadow-clay-raised">
<h4 className="font-baloo text-base font-bold text-clay-text">
{t("sharedWallet.analytics.settlementTitle")}
</h4>
<p className="text-xs text-clay-text-muted">
{t("sharedWallet.analytics.settlementSubtitle")}
</p>
<div className="flex flex-col divide-y divide-clay-border/40">
{analytics.settlementSummary.map((item) => {
const netNum = Number(item.netBalance || 0);
const isPositive = netNum >= 0;
return (
<div
key={item.userId}
className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0 text-xs"
>
<span className="font-bold text-clay-text">
{item.fullName || t("sharedWallet.analytics.defaultMemberName")}
</span>
<div className="flex items-center gap-4">
<div className="text-right">
<span className="text-[10px] text-clay-text-muted block">
{t("sharedWallet.analytics.injectedVsSpent")}
</span>
<span className="text-clay-text-muted">
{Number(item.totalInjected).toLocaleString("vi-VN")} / {Number(item.totalSpent).toLocaleString("vi-VN")}
</span>
</div>
<div className="text-right min-w-[70px]">
<span className="text-[10px] text-clay-text-muted block">
{t("sharedWallet.analytics.netDifference")}
</span>
<span className={`font-bold ${isPositive ? "text-clay-income" : "text-clay-expense"}`}>
{isPositive ? "+" : ""}{netNum.toLocaleString("vi-VN")}
</span>
</div>
</div>
</div>
);
})}
</div>
</Card>
)}
</div>
);
};
import React from "react";
import { WalletMemberRole } from "@/types/shared-wallet";
import { UsersIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n";
interface SharedWalletBadgeProps {
role?: WalletMemberRole;
showIcon?: boolean;
className?: string;
}
export const SharedWalletBadge: React.FC<SharedWalletBadgeProps> = ({
role,
showIcon = true,
className = "",
}) => {
const { t } = useI18n();
const roleConfig: Record<
WalletMemberRole,
{ label: string; bg: string; text: string; border: string }
> = {
OWNER: {
label: t("sharedWallet.badge.owner"),
bg: "bg-clay-badge-primary-bg",
text: "text-clay-badge-primary-text",
border: "border-clay-badge-primary-border",
},
MEMBER: {
label: t("sharedWallet.badge.member"),
bg: "bg-clay-badge-income-bg",
text: "text-clay-badge-income-text",
border: "border-clay-badge-income-border",
},
VIEWER: {
label: t("sharedWallet.badge.viewer"),
bg: "bg-clay-badge-warning-bg",
text: "text-clay-badge-warning-text",
border: "border-clay-badge-warning-border",
},
};
if (!role) {
return (
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-nunito font-bold rounded-full bg-clay-badge-info-bg text-clay-badge-info-text border border-clay-badge-info-border shadow-sm ${className}`}
>
{showIcon && <UsersIcon size={13} className="text-current" />}
<span>{t("sharedWallet.scope.shared")}</span>
</span>
);
}
const current = roleConfig[role];
return (
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs font-nunito font-bold rounded-full ${current.bg} ${current.text} border ${current.border} shadow-sm ${className}`}
>
{showIcon && <UsersIcon size={13} className="text-current" />}
<span>{current.label}</span>
</span>
);
};
import React, { useState } from "react";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
import { Avatar } from "@/components/ui/Avatar";
import { ProgressBar } from "@/components/ui/ProgressBar";
import { Modal } from "@/components/ui/Modal";
import { Input } from "@/components/ui/Input";
import { UserPlusIcon, LeaveIcon, UsersIcon, QrCodeIcon } from "@/components/ui/icons";
import { SharedWalletBadge } from "./SharedWalletBadge";
import { InviteMemberModal } from "./InviteMemberModal";
import {
WalletMember,
WalletMemberRole,
} from "@/types/shared-wallet";
import {
useCancelInvitation,
useLeaveSharedWallet,
useRemoveMember,
useSharedWalletInvitations,
useSharedWalletMembers,
useUpdateMember,
} from "@/hooks/use-shared-wallets";
import { useI18n } from "@/i18n";
interface WalletMembersTabProps {
walletId: string;
walletName: string;
myRole?: WalletMemberRole;
currency: string;
onWalletLeft?: () => void;
}
export const WalletMembersTab: React.FC<WalletMembersTabProps> = ({
walletId,
walletName,
myRole = "MEMBER",
currency,
onWalletLeft,
}) => {
const { t, formatDate } = useI18n();
const isOwner = myRole === "OWNER";
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [editingMember, setEditingMember] = useState<WalletMember | null>(null);
const [editLimit, setEditLimit] = useState("");
const [editNickname, setEditNickname] = useState("");
const [editRole, setEditRole] = useState<WalletMemberRole>("MEMBER");
const [confirmLeaveOpen, setConfirmLeaveOpen] = useState(false);
const { data: membersRes, isLoading } = useSharedWalletMembers(walletId);
const { data: invitationsRes } = useSharedWalletInvitations(walletId);
const updateMutation = useUpdateMember(walletId);
const removeMutation = useRemoveMember(walletId);
const cancelInviteMutation = useCancelInvitation(walletId);
const leaveMutation = useLeaveSharedWallet(walletId);
const members = membersRes?.data || [];
const invitations = invitationsRes?.data || [];
const handleOpenEdit = (m: WalletMember) => {
setEditingMember(m);
setEditNickname(m.nickname || "");
setEditLimit(m.monthlySpendingLimit || "");
setEditRole(m.role);
};
const handleSaveEdit = async () => {
if (!editingMember) return;
try {
await updateMutation.mutateAsync({
memberId: editingMember.id,
input: {
role: editRole,
nickname: editNickname.trim() || null,
monthlySpendingLimit: editLimit.trim() || null,
},
});
setEditingMember(null);
} catch (err) {
console.error("Failed to update member:", err);
}
};
const handleRemove = async (memberId: string) => {
if (!confirm(t("sharedWallet.membersTab.confirmRemovePrompt"))) return;
try {
await removeMutation.mutateAsync(memberId);
} catch (err) {
console.error("Failed to remove member:", err);
}
};
const handleLeave = async () => {
try {
await leaveMutation.mutateAsync();
setConfirmLeaveOpen(false);
if (onWalletLeft) {
onWalletLeft();
}
} catch (err) {
console.error("Failed to leave wallet:", err);
}
};
if (isLoading) {
return (
<div className="flex flex-col gap-3 p-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-20 rounded-clay bg-clay-bg/60 border border-clay-border animate-pulse"
/>
))}
</div>
);
}
return (
<div className="flex flex-col gap-5">
{/* Action Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="font-baloo text-lg font-bold text-clay-text flex items-center gap-2">
<UsersIcon size={20} className="text-clay-primary" />
<span>{t("sharedWallet.membersTab.title", { count: members.length })}</span>
</h3>
<p className="text-xs text-clay-text-muted">
{t("sharedWallet.membersTab.subtitle")}
</p>
</div>
{isOwner && (
<Button
variant="primary"
onClick={() => setIsInviteOpen(true)}
className="flex items-center gap-1.5 !py-2 !px-3 shadow-clay-raised"
>
<QrCodeIcon size={16} />
<span>{t("sharedWallet.membersTab.createInviteBtn")}</span>
</Button>
)}
</div>
{/* Members List */}
<div className="flex flex-col gap-3">
{members.map((m) => {
const isTargetOwner = m.role === "OWNER";
const currentSpentNum = Number(m.currentMonthSpent || 0);
const limitNum = m.monthlySpendingLimit ? Number(m.monthlySpendingLimit) : null;
const usagePercent = limitNum && limitNum > 0
? Math.min(100, Math.round((currentSpentNum / limitNum) * 100))
: 0;
return (
<Card
key={m.id}
className="flex flex-col gap-3 !p-4 border border-clay-border/80 shadow-clay-raised hover:shadow-clay-hover transition-all"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar
src={m.user.avatarUrl || undefined}
alt={m.user.fullName || "Member"}
size="md"
/>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-baloo font-bold text-clay-text text-base">
{m.nickname || m.user.fullName || t("sharedWallet.analytics.defaultMemberName")}
</span>
{m.nickname && m.user.fullName && (
<span className="text-xs text-clay-text-muted">
({m.user.fullName})
</span>
)}
</div>
<span className="text-[11px] text-clay-text-muted">
{formatDate(m.joinedAt)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<SharedWalletBadge role={m.role} />
{isOwner && !isTargetOwner && (
<button
type="button"
onClick={() => handleOpenEdit(m)}
className="p-1.5 rounded-clay-sm text-clay-text-muted hover:text-clay-primary hover:bg-clay-bg transition-all text-xs font-bold"
>
{t("common.edit")}
</button>
)}
</div>
</div>
{/* Monthly Spending Quota */}
{!isTargetOwner && (
<div className="flex flex-col gap-1.5 pt-2 border-t border-clay-border/40">
<div className="flex items-center justify-between text-xs">
<span className="text-clay-text-muted">
{t("sharedWallet.membersTab.spent")}:
</span>
<span className="font-bold text-clay-text">
{currentSpentNum.toLocaleString("vi-VN")} {currency}
{limitNum ? (
<span className="text-clay-text-muted font-normal">
{" "}/ {limitNum.toLocaleString("vi-VN")} {currency}
</span>
) : (
<span className="text-clay-income font-normal ml-1">
({t("sharedWallet.membersTab.unlimited")})
</span>
)}
</span>
</div>
{limitNum !== null && limitNum > 0 && (
<ProgressBar
value={usagePercent}
type={usagePercent >= 90 ? "expense" : usagePercent >= 75 ? "warning" : "primary"}
/>
)}
</div>
)}
</Card>
);
})}
</div>
{/* Pending Invitations Section (Owner only) */}
{isOwner && invitations.length > 0 && (
<div className="flex flex-col gap-2.5 mt-2">
<h4 className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.membersTab.pendingInvites", { count: invitations.length })}
</h4>
<div className="flex flex-col gap-2">
{invitations.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between p-3 rounded-clay-sm bg-clay-surface border border-clay-border/80 shadow-clay-raised text-xs"
>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-clay-primary text-sm tracking-wider">
{inv.inviteCode}
</span>
<SharedWalletBadge role={inv.role} />
</div>
<div className="flex items-center gap-3">
<span className="text-clay-text-muted text-[11px]">
{t("sharedWallet.membersTab.inviteExpires", { date: formatDate(inv.expiresAt) })}
</span>
<button
type="button"
onClick={() => cancelInviteMutation.mutate(inv.id)}
className="text-clay-expense hover:underline font-bold"
>
{t("sharedWallet.membersTab.cancelInvite")}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Leave Wallet Button for non-owners */}
{!isOwner && (
<div className="mt-4 pt-4 border-t border-clay-border/60">
<Button
variant="danger"
onClick={() => setConfirmLeaveOpen(true)}
className="w-full flex items-center justify-center gap-2"
>
<LeaveIcon size={18} />
<span>{t("sharedWallet.membersTab.leaveWallet")}</span>
</Button>
</div>
)}
{/* Edit Member Modal */}
<Modal
isOpen={Boolean(editingMember)}
onClose={() => setEditingMember(null)}
title={t("sharedWallet.membersTab.editMemberTitle", { name: editingMember?.nickname || editingMember?.user.fullName || "" })}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.membersTab.nickname")}
</label>
<Input
value={editNickname}
onChange={(e) => setEditNickname(e.target.value)}
placeholder={t("sharedWallet.membersTab.nicknamePlaceholder")}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.membersTab.role")}
</label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setEditRole("MEMBER")}
className={`py-2 text-xs font-bold rounded-clay-sm border transition-all ${
editRole === "MEMBER"
? "bg-clay-primary/10 border-clay-primary text-clay-primary shadow-clay-pressed"
: "bg-clay-surface border-clay-border text-clay-text"
}`}
>
{t("sharedWallet.inviteModal.roleMember")}
</button>
<button
type="button"
onClick={() => setEditRole("VIEWER")}
className={`py-2 text-xs font-bold rounded-clay-sm border transition-all ${
editRole === "VIEWER"
? "bg-clay-warning/10 border-clay-warning text-clay-warning shadow-clay-pressed"
: "bg-clay-surface border-clay-border text-clay-text"
}`}
>
{t("sharedWallet.inviteModal.roleViewer")}
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-clay-text uppercase tracking-wider">
{t("sharedWallet.membersTab.monthlySpendingLimit")} ({currency})
</label>
<Input
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
placeholder={t("sharedWallet.membersTab.monthlySpendingLimitPlaceholder")}
type="number"
/>
<span className="text-[11px] text-clay-text-muted">
{t("sharedWallet.membersTab.monthlySpendingLimitHint")}
</span>
</div>
<div className="flex gap-2 mt-2">
<Button
variant="danger"
onClick={() => editingMember && handleRemove(editingMember.id)}
className="flex-1"
>
{t("sharedWallet.membersTab.removeMember")}
</Button>
<Button
variant="primary"
onClick={handleSaveEdit}
disabled={updateMutation.isPending}
className="flex-1"
>
{updateMutation.isPending
? t("sharedWallet.membersTab.saving")
: t("sharedWallet.membersTab.saveChanges")}
</Button>
</div>
</div>
</Modal>
{/* Leave Wallet Confirmation Modal */}
<Modal
isOpen={confirmLeaveOpen}
onClose={() => setConfirmLeaveOpen(false)}
title={t("sharedWallet.membersTab.confirmLeaveTitle")}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-clay-text leading-relaxed">
{t("sharedWallet.membersTab.confirmLeaveMessage", { name: walletName })}
</p>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => setConfirmLeaveOpen(false)}
className="flex-1"
>
{t("sharedWallet.membersTab.cancel")}
</Button>
<Button
variant="danger"
onClick={handleLeave}
disabled={leaveMutation.isPending}
className="flex-1"
>
{leaveMutation.isPending
? t("sharedWallet.membersTab.leaving")
: t("sharedWallet.membersTab.confirmLeave")}
</Button>
</div>
</div>
</Modal>
{/* Invite Modal */}
<InviteMemberModal
isOpen={isInviteOpen}
onClose={() => setIsInviteOpen(false)}
walletId={walletId}
walletName={walletName}
/>
</div>
);
};
import React from "react"; import React from "react";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { ChevronRightIcon } from "@/components/ui/icons"; import { ChevronRightIcon, QrCodeIcon } from "@/components/ui/icons";
import { formatWalletBalance } from "@/lib/wallet-format"; import { formatWalletBalance } from "@/lib/wallet-format";
import { Wallet } from "@/types/wallet"; import { Wallet } from "@/types/wallet";
import { WalletArtwork } from "./WalletArtwork"; import { WalletArtwork } from "./WalletArtwork";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { SharedWalletBadge } from "@/components/shared-wallets/SharedWalletBadge";
import { MemberAvatarStack } from "@/components/shared-wallets/MemberAvatarStack";
interface WalletCardProps { interface WalletCardProps {
wallet: Wallet; wallet: Wallet;
onClick: () => void; onClick: () => void;
onSetDefault?: () => void; onSetDefault?: () => void;
isSettingDefault?: boolean; isSettingDefault?: boolean;
onShare?: (wallet: Wallet) => void;
} }
export const WalletCard: React.FC<WalletCardProps> = ({ export const WalletCard: React.FC<WalletCardProps> = ({
...@@ -19,6 +23,7 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -19,6 +23,7 @@ export const WalletCard: React.FC<WalletCardProps> = ({
onClick, onClick,
onSetDefault, onSetDefault,
isSettingDefault = false, isSettingDefault = false,
onShare,
}) => { }) => {
const { intlLocale, t } = useI18n(); const { intlLocale, t } = useI18n();
...@@ -42,28 +47,62 @@ export const WalletCard: React.FC<WalletCardProps> = ({ ...@@ -42,28 +47,62 @@ export const WalletCard: React.FC<WalletCardProps> = ({
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3> <h3 className="font-baloo text-lg font-bold text-clay-text truncate">{wallet.name}</h3>
{wallet.isDefault && <Badge type="primary">{t("common.default")}</Badge>} {wallet.isDefault && <Badge type="primary">{t("common.default")}</Badge>}
{wallet.isShared && <SharedWalletBadge role={wallet.myRole} />}
{wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>} {wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div> </div>
<p className="font-baloo text-xl font-bold text-clay-primary-dark truncate"> <p className="font-baloo text-xl font-bold text-clay-primary-dark truncate">
{formatWalletBalance(wallet.balance, wallet.currency, intlLocale)} {formatWalletBalance(wallet.balance, wallet.currency, intlLocale)}
</p> </p>
{wallet.isShared && wallet.membersSummary && (
<div className="flex items-center gap-2 mt-1">
<MemberAvatarStack
members={wallet.membersSummary.members.map((m) => ({
id: m.id,
name: m.nickname || m.user.fullName,
avatarUrl: m.user.avatarUrl,
}))}
size="sm"
/>
<span className="text-[11px] text-clay-text-muted">
{t("sharedWallet.card.memberCount", { count: wallet.membersSummary.totalActiveMembers })}
</span>
</div>
)}
{wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>} {wallet.description && <p className="clay-caption truncate mt-0.5">{wallet.description}</p>}
</div> </div>
<ChevronRightIcon className="shrink-0 text-clay-text-muted" /> <ChevronRightIcon className="shrink-0 text-clay-text-muted" />
</div> </div>
{!wallet.isDefault && !wallet.isArchived && onSetDefault && ( {(!wallet.isArchived) && (
<button <div className="mt-3 flex items-center gap-2 pt-2 border-t border-clay-border/30">
type="button" {onShare && (!wallet.isShared || wallet.myRole === "OWNER") && (
className="mt-3 w-full rounded-full bg-clay-bg px-3 py-2 font-nunito text-xs font-bold text-clay-primary-dark shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary disabled:opacity-50" <button
disabled={isSettingDefault} type="button"
onClick={(event) => { className="flex-1 flex items-center justify-center gap-1.5 rounded-full bg-clay-primary/10 border border-clay-primary/25 px-3 py-1.5 font-nunito text-xs font-bold text-clay-primary shadow-clay-pressed transition-all duration-200 ease-in-out hover:bg-clay-primary/20"
event.stopPropagation(); onClick={(event) => {
onSetDefault(); event.stopPropagation();
}} onShare(wallet);
> }}
{isSettingDefault ? t("common.updating") : t("wallet.setDefault")} >
</button> <QrCodeIcon size={15} />
<span>{t("sharedWallet.card.shareQr")}</span>
</button>
)}
{!wallet.isDefault && onSetDefault && (
<button
type="button"
className="flex-1 rounded-full bg-clay-bg px-3 py-1.5 font-nunito text-xs font-bold text-clay-primary-dark shadow-clay-pressed transition-all duration-200 ease-in-out hover:text-clay-primary disabled:opacity-50"
disabled={isSettingDefault}
onClick={(event) => {
event.stopPropagation();
onSetDefault();
}}
>
{isSettingDefault ? t("common.updating") : t("wallet.setDefault")}
</button>
)}
</div>
)} )}
</Card> </Card>
); );
......
...@@ -691,6 +691,88 @@ export const PiggyBankIcon: React.FC<IconProps> = ({ size = 20, ...props }) => ( ...@@ -691,6 +691,88 @@ export const PiggyBankIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg> </svg>
); );
// Shared Wallets / Users Icon
export const UsersIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
// UserPlus Icon
export const UserPlusIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="8.5" cy="7" r="4" />
<line x1="20" y1="8" x2="20" y2="14" />
<line x1="23" y1="11" x2="17" y2="11" />
</svg>
);
// QrCode Icon
export const QrCodeIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
// Leave Icon
export const LeaveIcon: React.FC<IconProps> = ({ size = 18, ...props }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { sharedWalletService } from "@/services/shared-wallet.service";
import {
AcceptInvitationInput,
CreateInvitationInput,
UpdateMemberInput,
} from "@/types/shared-wallet";
import { walletKeys } from "./use-wallets";
export const sharedWalletKeys = {
all: ["shared-wallets"] as const,
invitations: (walletId: string) => [...sharedWalletKeys.all, walletId, "invitations"] as const,
preview: (code: string) => [...sharedWalletKeys.all, "preview", code] as const,
members: (walletId: string) => [...sharedWalletKeys.all, walletId, "members"] as const,
analytics: (walletId: string, from?: string, to?: string) =>
[...sharedWalletKeys.all, walletId, "analytics", from, to] as const,
};
export function useSharedWalletInvitations(walletId: string) {
return useQuery({
queryKey: sharedWalletKeys.invitations(walletId),
queryFn: () => sharedWalletService.getInvitations(walletId),
enabled: Boolean(walletId),
staleTime: 30_000,
});
}
export function useInvitationPreview(code: string, enabled = true) {
return useQuery({
queryKey: sharedWalletKeys.preview(code),
queryFn: () => sharedWalletService.previewInvitation(code),
enabled: Boolean(code && code.trim().length >= 4) && enabled,
staleTime: 60_000,
retry: false,
});
}
export function useCreateInvitation(walletId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input?: CreateInvitationInput) =>
sharedWalletService.createInvitation(walletId, input),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.invitations(walletId),
});
},
});
}
export function useCancelInvitation(walletId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (inviteId: string) =>
sharedWalletService.cancelInvitation(walletId, inviteId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.invitations(walletId),
});
},
});
}
export function useAcceptInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: AcceptInvitationInput) =>
sharedWalletService.acceptInvitation(input),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
await queryClient.invalidateQueries({ queryKey: sharedWalletKeys.all });
},
});
}
export function useSharedWalletMembers(walletId: string) {
return useQuery({
queryKey: sharedWalletKeys.members(walletId),
queryFn: () => sharedWalletService.getMembers(walletId),
enabled: Boolean(walletId),
staleTime: 30_000,
});
}
export function useUpdateMember(walletId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
memberId,
input,
}: {
memberId: string;
input: UpdateMemberInput;
}) => sharedWalletService.updateMember(walletId, memberId, input),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.members(walletId),
});
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.analytics(walletId),
});
await queryClient.invalidateQueries({ queryKey: walletKeys.detail(walletId) });
},
});
}
export function useRemoveMember(walletId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (memberId: string) =>
sharedWalletService.removeMember(walletId, memberId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.members(walletId),
});
await queryClient.invalidateQueries({
queryKey: sharedWalletKeys.analytics(walletId),
});
await queryClient.invalidateQueries({ queryKey: walletKeys.detail(walletId) });
},
});
}
export function useLeaveSharedWallet(walletId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => sharedWalletService.leaveWallet(walletId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: walletKeys.all });
},
});
}
export function useSharedWalletAnalytics(
walletId: string,
query?: { from?: string; to?: string },
) {
return useQuery({
queryKey: sharedWalletKeys.analytics(walletId, query?.from, query?.to),
queryFn: () => sharedWalletService.getAnalytics(walletId, query),
enabled: Boolean(walletId),
staleTime: 60_000,
});
}
...@@ -477,6 +477,132 @@ ...@@ -477,6 +477,132 @@
"icons": { "wallet": "Wallet", "cash": "Cash", "bank": "Bank", "card": "Card", "savings": "Savings" }, "icons": { "wallet": "Wallet", "cash": "Cash", "bank": "Bank", "card": "Card", "savings": "Savings" },
"sort": { "createdDesc": "Recently created", "updatedDesc": "Recently updated", "nameAsc": "Name A → Z", "nameDesc": "Name Z → A", "balanceDesc": "Highest balance", "balanceAsc": "Lowest balance" } "sort": { "createdDesc": "Recently created", "updatedDesc": "Recently updated", "nameAsc": "Name A → Z", "nameDesc": "Name Z → A", "balanceDesc": "Highest balance", "balanceAsc": "Lowest balance" }
}, },
"sharedWallet": {
"badge": {
"owner": "Owner",
"member": "Member",
"viewer": "Viewer"
},
"scope": {
"all": "All wallets",
"allTransactions": "All",
"allReports": "All",
"personal": "Personal",
"shared": "Shared",
"familyFund": "Shared Fund"
},
"card": {
"shareQr": "Share / QR Code",
"memberCount": "{{count}} members",
"moreMembers": "+{{count}} more members",
"sharedTag": "Shared",
"paidBy": "Paid by {{name}}"
},
"tabs": {
"info": "Information",
"members": "Members",
"analytics": "Fund Analytics"
},
"promo": {
"title": "Share wallet with family / partner",
"subtitle": "Co-manage expenses with transparent joint budgeting",
"createQrBtn": "Create QR & Invite"
},
"type": {
"label": "Wallet Type",
"shared": "Shared Wallet",
"personal": "Personal Wallet"
},
"inviteModal": {
"title": "Invite members to \"{{name}}\"",
"description": "Create an invite code or QR code to share wallet access with family members, partner, or roommates.",
"roleLabel": "Member Role",
"roleMember": "Member",
"roleMemberDesc": "Can view and log expenses",
"roleViewer": "Viewer",
"roleViewerDesc": "View-only access to balances and history",
"expiryLabel": "Invitation Expiration",
"expiry1Day": "24 hours",
"expiry7Days": "7 days",
"expiry30Days": "30 days",
"createBtn": "Generate Invite Code & QR",
"creating": "Generating code...",
"qrAlt": "Wallet invitation QR code",
"inviteCodeLabel": "Wallet Invite Code",
"copied": "Copied",
"copy": "Copy",
"guideTitle": "Instructions:",
"guideDesc": "Invited members can open FinWise, navigate to Wallets → tap \"Join Wallet\" to scan QR or enter this code.",
"done": "Done"
},
"joinModal": {
"title": "Join Shared Wallet",
"button": "Join Wallet",
"scanCamera": "Scan QR with Zalo Camera",
"openingCamera": "Opening camera...",
"orManual": "Or enter invite code manually",
"codeLabel": "Invite Code (6-8 characters)",
"codePlaceholder": "e.g. FW-A1B2C3",
"checking": "Verifying invite code...",
"invitedTo": "You were invited to:",
"inviter": "Invited by:",
"memberCount": "{{count}} members",
"invalidOrExpired": "Invite code does not exist or has expired",
"nicknameLabel": "Your nickname in this wallet (Optional)",
"nicknamePlaceholder": "e.g. Honey, Partner, John...",
"joinBtn": "Join Now",
"joining": "Joining...",
"cameraError": "Unable to open QR camera in browser or scan was cancelled. Please enter code manually below.",
"noQrContent": "No QR content recognized.",
"invalidQrFormat": "Invalid FinWise wallet QR code format.",
"joinFailed": "Could not join wallet. Please check your invite code."
},
"membersTab": {
"title": "Members ({{count}})",
"subtitle": "Manage roles and monthly spending limits in the shared fund",
"createInviteBtn": "Generate Invite & QR",
"monthlyLimitLabel": "Monthly Limit",
"unlimited": "No limit",
"spent": "Spent",
"remaining": "Remaining",
"overLimit": "Exceeded limit",
"pendingInvites": "Pending Invitations ({{count}})",
"inviteExpires": "Expires: {{date}}",
"cancelInvite": "Revoke",
"leaveWallet": "Leave Shared Wallet",
"editMemberTitle": "Edit Member: {{name}}",
"nickname": "Wallet Nickname",
"nicknamePlaceholder": "e.g. Honey, Sister, Alex...",
"role": "Role",
"monthlySpendingLimit": "Monthly Spending Limit (VND)",
"monthlySpendingLimitPlaceholder": "Leave blank for no limit",
"monthlySpendingLimitHint": "Member will not be able to log transactions exceeding this amount in the current month.",
"removeMember": "Remove from Wallet",
"confirmRemovePrompt": "Are you sure you want to remove this member from the wallet?",
"saveChanges": "Save Changes",
"saving": "Saving...",
"confirmLeaveTitle": "Confirm Leave Wallet",
"confirmLeaveMessage": "Are you sure you want to leave \"{{name}}\"? You will lose access to view and record transactions unless invited again.",
"cancel": "Cancel",
"confirmLeave": "Leave Wallet",
"leaving": "Leaving wallet...",
"updateSuccess": "Member details updated successfully.",
"removeSuccess": "Member removed from wallet.",
"cancelInviteSuccess": "Invitation cancelled.",
"leaveSuccess": "Successfully left the shared wallet."
},
"analytics": {
"totalSpent": "Total Fund Expenses",
"totalInjected": "Total Funded",
"breakdownTitle": "Spending Share by Member",
"memberCount": "{{count}} members",
"defaultMemberName": "Member",
"settlementTitle": "Contributions & Net Settlement",
"settlementSubtitle": "Track funds contributed vs spent by each participant",
"injectedVsSpent": "Funded / Spent",
"netDifference": "Net Balance"
}
},
"category": { "category": {
"header": "Categories", "header": "Categories",
"overview": "Category overview", "overview": "Category overview",
......
...@@ -494,6 +494,132 @@ ...@@ -494,6 +494,132 @@
"balanceAsc": "Số dư thấp nhất" "balanceAsc": "Số dư thấp nhất"
} }
}, },
"sharedWallet": {
"badge": {
"owner": "Chủ ví",
"member": "Thành viên",
"viewer": "Chỉ xem"
},
"scope": {
"all": "Tất cả ví",
"allTransactions": "Tất cả",
"allReports": "Tất cả",
"personal": "Cá nhân",
"shared": "Dùng chung",
"familyFund": "Quỹ dùng chung"
},
"card": {
"shareQr": "Chia sẻ / Mã QR",
"memberCount": "{{count}} thành viên",
"moreMembers": "+{{count}} thành viên khác",
"sharedTag": "Ví chung",
"paidBy": "Chi bởi {{name}}"
},
"tabs": {
"info": "Thông tin",
"members": "Thành viên",
"analytics": "Báo cáo quỹ"
},
"promo": {
"title": "Chia sẻ ví cho gia đình / người yêu",
"subtitle": "Cùng quản lý chi tiêu và minh bạch ngân sách",
"createQrBtn": "Tạo mã QR & Mời"
},
"type": {
"label": "Loại ví",
"shared": "Ví dùng chung",
"personal": "Ví cá nhân"
},
"inviteModal": {
"title": "Mời thành viên vào \"{{name}}\"",
"description": "Tạo mã mời hoặc mã QR để chia sẻ quyền truy cập ví chung cho người thân, người yêu hoặc bạn cùng phòng.",
"roleLabel": "Vai trò của thành viên",
"roleMember": "Thành viên",
"roleMemberDesc": "Được xem và ghi chép chi tiêu",
"roleViewer": "Chỉ xem",
"roleViewerDesc": "Chỉ theo dõi số dư & giao dịch",
"expiryLabel": "Thời hạn mã mời",
"expiry1Day": "24 giờ",
"expiry7Days": "7 ngày",
"expiry30Days": "30 ngày",
"createBtn": "Tạo mã mời & QR",
"creating": "Đang tạo mã...",
"qrAlt": "QR Code mời tham gia ví",
"inviteCodeLabel": "Mã mời tham gia ví",
"copied": "Đã sao chép",
"copy": "Sao chép",
"guideTitle": "Hướng dẫn:",
"guideDesc": "Người được mời chỉ cần mở FinWise, vào mục Ví → bấm nút \"Tham gia ví\" và quét QR hoặc nhập mã trên.",
"done": "Hoàn tất"
},
"joinModal": {
"title": "Tham gia Ví dùng chung",
"button": "Tham gia ví",
"scanCamera": "Quét mã QR bằng Camera Zalo",
"openingCamera": "Đang mở Camera...",
"orManual": "Hoặc nhập mã mời thủ công",
"codeLabel": "Mã mời (6-8 ký tự)",
"codePlaceholder": "Ví dụ: FW-A1B2C3",
"checking": "Đang kiểm tra mã mời...",
"invitedTo": "Bạn được mời vào:",
"inviter": "Người mời:",
"memberCount": "{{count}} thành viên",
"invalidOrExpired": "Mã mời không tồn tại hoặc đã hết hạn",
"nicknameLabel": "Biệt danh của bạn trong ví này (Tùy chọn)",
"nicknamePlaceholder": "Ví dụ: Vợ yêu, Chồng, Nam...",
"joinBtn": "Tham gia ngay",
"joining": "Đang tham gia...",
"cameraError": "Không thể mở camera quét QR trên trình duyệt hoặc bạn đã hủy quét. Vui lòng nhập mã bên dưới.",
"noQrContent": "Không nhận diện được nội dung từ mã QR.",
"invalidQrFormat": "Mã QR không đúng định dạng lời mời ví FinWise.",
"joinFailed": "Không thể tham gia ví. Vui lòng kiểm tra lại mã mời."
},
"membersTab": {
"title": "Thành viên ({{count}})",
"subtitle": "Quản lý vai trò và hạn mức chi tiêu trong quỹ chung",
"createInviteBtn": "Tạo mã mời & QR",
"monthlyLimitLabel": "Hạn mức tháng",
"unlimited": "Không giới hạn",
"spent": "Đã chi",
"remaining": "Còn lại",
"overLimit": "Vượt hạn mức",
"pendingInvites": "Mã mời đang chờ chấp nhận ({{count}})",
"inviteExpires": "Hết hạn: {{date}}",
"cancelInvite": "Hủy",
"leaveWallet": "Rời ví dùng chung",
"editMemberTitle": "Chỉnh sửa thành viên: {{name}}",
"nickname": "Biệt danh trong ví",
"nicknamePlaceholder": "Ví dụ: Vợ yêu, Em gái, Nam...",
"role": "Vai trò",
"monthlySpendingLimit": "Hạn mức chi tiêu tháng (VND)",
"monthlySpendingLimitPlaceholder": "Bỏ trống nếu không giới hạn",
"monthlySpendingLimitHint": "Thành viên sẽ không thể tạo giao dịch nếu chi tiêu vượt quá số tiền này trong tháng.",
"removeMember": "Xóa khỏi ví",
"confirmRemovePrompt": "Bạn có chắc chắn muốn xóa thành viên này khỏi ví?",
"saveChanges": "Lưu thay đổi",
"saving": "Đang lưu...",
"confirmLeaveTitle": "Xác nhận rời khỏi ví",
"confirmLeaveMessage": "Bạn có chắc chắn muốn rời khỏi ví dùng chung \"{{name}}\"? Bạn sẽ không còn quyền xem hay thêm giao dịch vào ví này trừ khi được mời lại.",
"cancel": "Hủy",
"confirmLeave": "Rời ví",
"leaving": "Đang rời ví...",
"updateSuccess": "Đã cập nhật thông tin thành viên thành công.",
"removeSuccess": "Đã xóa thành viên khỏi ví.",
"cancelInviteSuccess": "Đã hủy mã mời.",
"leaveSuccess": "Đã rời ví dùng chung."
},
"analytics": {
"totalSpent": "Tổng chi quỹ",
"totalInjected": "Đã nạp vào",
"breakdownTitle": "Tỷ lệ chi tiêu theo thành viên",
"memberCount": "{{count}} thành viên",
"defaultMemberName": "Thành viên",
"settlementTitle": "Bảng đóng góp & Quyết toán",
"settlementSubtitle": "Theo dõi khoản nạp vào so với khoản đã chi của từng người",
"injectedVsSpent": "Nạp / Chi",
"netDifference": "Chênh lệch"
}
},
"category": { "category": {
"header": "Quản lý danh mục", "header": "Quản lý danh mục",
"overview": "Tổng quan danh mục", "overview": "Tổng quan danh mục",
......
...@@ -50,8 +50,151 @@ const GUEST_WALLETS = [ ...@@ -50,8 +50,151 @@ const GUEST_WALLETS = [
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}, },
{
id: "wallet-guest-shared",
name: "Quỹ Chung Gia Đình",
balance: "18500000",
currency: "VND",
icon: "family",
color: "#F59E0B",
description: "Quỹ chi tiêu chung của gia đình",
isDefault: false,
isArchived: false,
isShared: true,
myRole: "OWNER" as const,
membersSummary: {
totalActiveMembers: 2,
members: [
{
id: "member-guest-1",
userId: GUEST_USER.id,
role: "OWNER" as const,
nickname: "Chồng",
user: {
id: GUEST_USER.id,
fullName: GUEST_USER.fullName,
avatarUrl: GUEST_USER.avatarUrl,
},
},
{
id: "member-guest-2",
userId: "guest-partner-id",
role: "MEMBER" as const,
nickname: "Vợ yêu",
user: {
id: "guest-partner-id",
fullName: "Nguyễn Thị Hoa",
avatarUrl: null,
},
},
],
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]; ];
const GUEST_MEMBERS = [
{
id: "member-guest-1",
walletId: "wallet-guest-shared",
userId: GUEST_USER.id,
role: "OWNER" as const,
nickname: "Chồng",
status: "ACTIVE" as const,
monthlySpendingLimit: null,
currentMonthSpent: "3500000.00",
remainingLimit: null,
joinedAt: new Date().toISOString(),
user: {
id: GUEST_USER.id,
fullName: GUEST_USER.fullName,
avatarUrl: GUEST_USER.avatarUrl,
email: GUEST_USER.email,
phoneNumber: GUEST_USER.phoneNumber,
},
},
{
id: "member-guest-2",
walletId: "wallet-guest-shared",
userId: "guest-partner-id",
role: "MEMBER" as const,
nickname: "Vợ yêu",
status: "ACTIVE" as const,
monthlySpendingLimit: "5000000.00",
currentMonthSpent: "2450000.00",
remainingLimit: "2550000.00",
joinedAt: new Date().toISOString(),
user: {
id: "guest-partner-id",
fullName: "Nguyễn Thị Hoa",
avatarUrl: null,
email: "hoa.nguyen@example.com",
phoneNumber: "0987654321",
},
},
];
const GUEST_SHARED_ANALYTICS = {
walletId: "wallet-guest-shared",
walletName: "Quỹ Chung Gia Đình",
currency: "VND",
period: {
from: startOfMonthStr,
to: todayStr,
},
totalIncome: "25000000.00",
totalExpense: "5950000.00",
totalSpent: "5950000.00",
currentBalance: "18500000.00",
memberBreakdown: [
{
userId: GUEST_USER.id,
fullName: GUEST_USER.fullName,
avatarUrl: GUEST_USER.avatarUrl,
nickname: "Chồng",
role: "OWNER" as const,
spentAmount: "3500000.00",
totalSpent: "3500000.00",
percentage: 58.8,
spendingPercentage: "58.8",
transactionCount: 8,
monthlyLimit: null,
limitUsagePercentage: null,
},
{
userId: "guest-partner-id",
fullName: "Nguyễn Thị Hoa",
avatarUrl: null,
nickname: "Vợ yêu",
role: "MEMBER" as const,
spentAmount: "2450000.00",
totalSpent: "2450000.00",
percentage: 41.2,
spendingPercentage: "41.2",
transactionCount: 6,
monthlyLimit: "5000000.00",
limitUsagePercentage: 49.0,
},
],
settlementSummary: [
{
userId: GUEST_USER.id,
fullName: "Chồng",
totalInjected: "15000000.00",
totalSpent: "3500000.00",
netBalance: "11500000.00",
},
{
userId: "guest-partner-id",
fullName: "Vợ yêu",
totalInjected: "10000000.00",
totalSpent: "2450000.00",
netBalance: "7550000.00",
},
],
};
const GUEST_CATEGORIES = [ const GUEST_CATEGORIES = [
{ {
id: "cat-income-1", id: "cat-income-1",
...@@ -1290,12 +1433,35 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes ...@@ -1290,12 +1433,35 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
if (method === "get") { if (method === "get") {
if (url.includes("/auth/me")) { if (url.includes("/auth/me")) {
responseData = { success: true, data: GUEST_USER }; responseData = { success: true, data: GUEST_USER };
} else if (url.includes("/wallets/invitations/preview/")) {
responseData = {
success: true,
data: {
walletId: "wallet-guest-shared",
walletName: "Quỹ Chung Gia Đình",
inviterName: GUEST_USER.fullName,
inviterAvatar: GUEST_USER.avatarUrl,
role: "MEMBER",
activeMemberCount: 2,
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
},
};
} else if (url.includes("/analytics")) {
responseData = { success: true, data: GUEST_SHARED_ANALYTICS };
} else if (url.includes("/members")) {
responseData = { success: true, data: GUEST_MEMBERS };
} else if (url.includes("/invitations")) {
responseData = { success: true, data: [] };
} else if (url.includes("/wallets") && !url.match(/\/wallets\/[^/?]+$/)) { } else if (url.includes("/wallets") && !url.match(/\/wallets\/[^/?]+$/)) {
const scope = config.params?.spendingScope;
let filtered = GUEST_WALLETS;
if (scope === "PERSONAL") filtered = GUEST_WALLETS.filter((w) => !w.isShared);
else if (scope === "SHARED") filtered = GUEST_WALLETS.filter((w) => w.isShared);
responseData = { responseData = {
success: true, success: true,
data: GUEST_WALLETS, data: filtered,
items: GUEST_WALLETS, items: filtered,
meta: makeMeta(GUEST_WALLETS.length, 1, 100), meta: makeMeta(filtered.length, 1, 100),
}; };
} else if (url.match(/\/wallets\/[^/?]+$/)) { } else if (url.match(/\/wallets\/[^/?]+$/)) {
const id = url.split("/wallets/")[1]?.split("?")[0]; const id = url.split("/wallets/")[1]?.split("?")[0];
...@@ -1725,6 +1891,39 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes ...@@ -1725,6 +1891,39 @@ export function handleGuestRequest(config: InternalAxiosRequestConfig): AxiosRes
totalAtMaturity: String(principal + estInterest), totalAtMaturity: String(principal + estInterest),
}, },
}; };
} else if (url.includes("/invitations/accept")) {
responseData = {
success: true,
data: GUEST_MEMBERS[1],
message: "Đã tham gia ví dùng chung thành công trong chế độ dùng thử",
};
} else if (url.includes("/invitations")) {
responseData = {
success: true,
data: {
id: "inv-mock-" + Date.now(),
walletId: "wallet-guest-shared",
inviterId: GUEST_USER.id,
role: "MEMBER",
inviteCode: "FW-GUEST7",
qrCodeDataUrl: "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' fill='white'/><rect x='10' y='10' width='30' height='30' fill='black'/><rect x='60' y='10' width='30' height='30' fill='black'/><rect x='10' y='60' width='30' height='30' fill='black'/></svg>",
status: "PENDING",
expiresAt: new Date(Date.now() + 7 * 86400000).toISOString(),
createdAt: new Date().toISOString(),
},
};
} else if (url.includes("/members/leave") || url.includes("/leave")) {
responseData = {
success: true,
data: { id: "wallet-guest-shared" },
message: "Đã rời ví dùng chung trong chế độ dùng thử",
};
} else if (url.includes("/members")) {
responseData = {
success: true,
data: GUEST_MEMBERS[1],
message: "Đã cập nhật thành viên trong chế độ dùng thử",
};
} else if (url.includes("/savings")) { } else if (url.includes("/savings")) {
responseData = { responseData = {
success: true, success: true,
......
...@@ -51,6 +51,7 @@ const ReportsPage: React.FC = () => { ...@@ -51,6 +51,7 @@ const ReportsPage: React.FC = () => {
const [dateFrom, setDateFrom] = useState(initialDates.from); const [dateFrom, setDateFrom] = useState(initialDates.from);
const [dateTo, setDateTo] = useState(initialDates.to); const [dateTo, setDateTo] = useState(initialDates.to);
const [walletId, setWalletId] = useState(""); const [walletId, setWalletId] = useState("");
const [spendingScope, setSpendingScope] = useState<"ALL" | "PERSONAL" | "SHARED">("ALL");
const [activeCurrency, setActiveCurrency] = useState(""); const [activeCurrency, setActiveCurrency] = useState("");
const walletsQuery = useWalletSearch({ includeArchived: false, sortBy: "name", order: "asc" }, true); const walletsQuery = useWalletSearch({ includeArchived: false, sortBy: "name", order: "asc" }, true);
...@@ -70,8 +71,9 @@ const ReportsPage: React.FC = () => { ...@@ -70,8 +71,9 @@ const ReportsPage: React.FC = () => {
dateTo: toInclusiveBoundary(dateTo, true), dateTo: toInclusiveBoundary(dateTo, true),
} : {}), } : {}),
...(walletId ? { walletId } : {}), ...(walletId ? { walletId } : {}),
spendingScope: spendingScope === "ALL" ? undefined : spendingScope,
granularity: "AUTO", granularity: "AUTO",
}), [customDateOrderValid, dateFrom, dateTo, period, walletId]); }), [customDateOrderValid, dateFrom, dateTo, period, walletId, spendingScope]);
const overviewQuery = useReportOverview(reportQuery, queryEnabled); const overviewQuery = useReportOverview(reportQuery, queryEnabled);
const cashFlowQuery = useCashFlowReport(reportQuery, queryEnabled); const cashFlowQuery = useCashFlowReport(reportQuery, queryEnabled);
...@@ -177,6 +179,30 @@ const ReportsPage: React.FC = () => { ...@@ -177,6 +179,30 @@ const ReportsPage: React.FC = () => {
</div> </div>
</Card> </Card>
{/* Scope Tabs */}
<div className="grid grid-cols-3 gap-2 p-1.5 rounded-clay bg-clay-surface border border-clay-border/80 shadow-clay-pressed">
{(
[
{ key: "ALL", label: t("sharedWallet.scope.allReports") },
{ key: "PERSONAL", label: t("sharedWallet.scope.personal") },
{ key: "SHARED", label: t("sharedWallet.scope.familyFund") },
] as const
).map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setSpendingScope(tab.key)}
className={`py-2 text-xs font-nunito font-bold rounded-clay-sm transition-all duration-200 ${
spendingScope === tab.key
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`}
>
{tab.label}
</button>
))}
</div>
<ReportFilters <ReportFilters
period={period} period={period}
dateFrom={dateFrom} dateFrom={dateFrom}
......
...@@ -247,7 +247,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -247,7 +247,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
// Active wallets list // Active wallets list
const walletOptions = useMemo(() => { const walletOptions = useMemo(() => {
const list = walletsQuery.data?.data || []; const list = walletsQuery.data?.data || [];
const filtered = list.filter((w) => !w.isArchived); const filtered = list.filter((w) => !w.isArchived && w.myRole !== "VIEWER");
// If editing and the wallet is archived, temporarily append it // If editing and the wallet is archived, temporarily append it
if (transaction && transaction.wallet) { if (transaction && transaction.wallet) {
...@@ -271,7 +271,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({ ...@@ -271,7 +271,7 @@ export const TransactionFormModal: React.FC<TransactionFormModalProps> = ({
{ value: "", label: t("transaction.selectWallet") }, { value: "", label: t("transaction.selectWallet") },
...filtered.map((w) => ({ ...filtered.map((w) => ({
value: w.id, value: w.id,
label: `${w.name} (${w.currency})${w.isArchived ? ` (${t("common.archived")})` : ""}`, label: `${w.name} (${w.currency})${w.isShared ? ` [${t("sharedWallet.card.sharedTag")}]` : ""}${w.isArchived ? ` (${t("common.archived")})` : ""}`,
})), })),
]; ];
}, [walletsQuery.data?.data, transaction, t]); }, [walletsQuery.data?.data, transaction, t]);
......
...@@ -127,6 +127,7 @@ const TransactionsPage: React.FC = () => { ...@@ -127,6 +127,7 @@ const TransactionsPage: React.FC = () => {
const [type, setType] = useState<TransactionType | "">(initialTransactionType || ""); const [type, setType] = useState<TransactionType | "">(initialTransactionType || "");
const [dateFrom, setDateFrom] = useState(initialDateFrom || ""); const [dateFrom, setDateFrom] = useState(initialDateFrom || "");
const [dateTo, setDateTo] = useState(initialDateTo || ""); const [dateTo, setDateTo] = useState(initialDateTo || "");
const [spendingScope, setSpendingScope] = useState<"ALL" | "PERSONAL" | "SHARED">("ALL");
const [minAmount, setMinAmount] = useState(""); const [minAmount, setMinAmount] = useState("");
const [maxAmount, setMaxAmount] = useState(""); const [maxAmount, setMaxAmount] = useState("");
const [sort, setSort] = useState("date:desc"); const [sort, setSort] = useState("date:desc");
...@@ -209,6 +210,7 @@ const TransactionsPage: React.FC = () => { ...@@ -209,6 +210,7 @@ const TransactionsPage: React.FC = () => {
if (walletId) q.walletId = walletId; if (walletId) q.walletId = walletId;
if (categoryId) q.categoryId = categoryId; if (categoryId) q.categoryId = categoryId;
if (type) q.type = type; if (type) q.type = type;
if (spendingScope && spendingScope !== "ALL") q.spendingScope = spendingScope;
if (dateFrom) { if (dateFrom) {
q.dateFrom = dateFrom; q.dateFrom = dateFrom;
...@@ -221,7 +223,7 @@ const TransactionsPage: React.FC = () => { ...@@ -221,7 +223,7 @@ const TransactionsPage: React.FC = () => {
if (maxAmount) q.maxAmount = maxAmount; if (maxAmount) q.maxAmount = maxAmount;
return q; return q;
}, [deferredSearch, walletId, categoryId, type, dateFrom, dateTo, minAmount, maxAmount, sortBy, order, page]); }, [deferredSearch, walletId, categoryId, type, spendingScope, dateFrom, dateTo, minAmount, maxAmount, sortBy, order, page]);
// Main Transactions Query // Main Transactions Query
const transactionsQuery = useTransactions(query); const transactionsQuery = useTransactions(query);
...@@ -617,6 +619,33 @@ const TransactionsPage: React.FC = () => { ...@@ -617,6 +619,33 @@ const TransactionsPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Scope Tabs */}
<div className="grid grid-cols-3 gap-2 p-1.5 rounded-clay bg-clay-surface border border-clay-border/80 shadow-clay-pressed">
{(
[
{ key: "ALL", label: t("sharedWallet.scope.allTransactions") },
{ key: "PERSONAL", label: t("sharedWallet.scope.personal") },
{ key: "SHARED", label: t("sharedWallet.scope.shared") },
] as const
).map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => {
setSpendingScope(tab.key);
setPage(1);
}}
className={`py-2 text-xs font-nunito font-bold rounded-clay-sm transition-all duration-200 ${
spendingScope === tab.key
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Search and Filters panel */} {/* Search and Filters panel */}
<Card className="flex flex-col gap-3 p-4"> <Card className="flex flex-col gap-3 p-4">
<div className="relative"> <div className="relative">
...@@ -844,8 +873,16 @@ const TransactionsPage: React.FC = () => { ...@@ -844,8 +873,16 @@ const TransactionsPage: React.FC = () => {
<h3 className="font-nunito text-sm font-bold text-clay-text truncate"> <h3 className="font-nunito text-sm font-bold text-clay-text truncate">
{item.description || getCategoryDisplayName(item.category, t)} {item.description || getCategoryDisplayName(item.category, t)}
</h3> </h3>
<p className="clay-caption truncate mt-0.5"> <p className="clay-caption truncate mt-0.5 flex items-center gap-1.5 flex-wrap">
{item.wallet.name} {item.isSharedWallet && (
<span className="inline-flex items-center px-1.5 py-0.2 rounded-full bg-clay-badge-info-bg text-clay-badge-info-text text-[10px] font-bold">
{t("sharedWallet.card.sharedTag")}
</span>
)}
<span>{item.wallet.name}</span>
{item.createdBy?.fullName && item.isSharedWallet && (
<span className="text-clay-primary font-semibold">· {t("sharedWallet.card.paidBy", { name: item.createdBy.fullName })}</span>
)}
{item.location && ` · ${item.location}`} {item.location && ` · ${item.location}`}
</p> </p>
</div> </div>
......
...@@ -21,6 +21,13 @@ import { WalletFormModal } from "./components/WalletFormModal"; ...@@ -21,6 +21,13 @@ import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton"; import { WalletSkeleton } from "./components/WalletSkeleton";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { Tabs } from "@/components/ui/Tabs";
import { SharedWalletBadge } from "@/components/shared-wallets/SharedWalletBadge";
import { WalletMembersTab } from "@/components/shared-wallets/WalletMembersTab";
import { SharedWalletAnalyticsCard } from "@/components/shared-wallets/SharedWalletAnalyticsCard";
import { InviteMemberModal } from "@/components/shared-wallets/InviteMemberModal";
import { UsersIcon, UserPlusIcon, QrCodeIcon } from "@/components/ui/icons";
const WalletDetailPage: React.FC = () => { const WalletDetailPage: React.FC = () => {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const walletId = params.id || ""; const walletId = params.id || "";
...@@ -28,14 +35,17 @@ const WalletDetailPage: React.FC = () => { ...@@ -28,14 +35,17 @@ const WalletDetailPage: React.FC = () => {
const location = useLocation(); const location = useLocation();
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const { formatDate, intlLocale, t } = useI18n(); const { formatDate, intlLocale, t } = useI18n();
const [activeTab, setActiveTab] = useState<string>("info");
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
const [isArchiveOpen, setIsArchiveOpen] = useState(false); const [isArchiveOpen, setIsArchiveOpen] = useState(false);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const walletQuery = useWallet(walletId); const walletQuery = useWallet(walletId);
const updateMutation = useUpdateWallet(walletId); const updateMutation = useUpdateWallet(walletId);
const setDefaultMutation = useSetDefaultWallet(); const setDefaultMutation = useSetDefaultWallet();
const archiveMutation = useArchiveWallet(); const archiveMutation = useArchiveWallet();
const restoreMutation = useRestoreWallet(); const restoreMutation = useRestoreWallet();
const wallet = walletQuery.data?.data; const wallet = walletQuery.data?.data;
const isOwner = wallet?.myRole === "OWNER" || !wallet?.myRole;
const showError = (error: unknown, fallback: string) => { const showError = (error: unknown, fallback: string) => {
openSnackbar({ type: "error", text: getErrorMessage(error, fallback) }); openSnackbar({ type: "error", text: getErrorMessage(error, fallback) });
...@@ -76,12 +86,18 @@ const WalletDetailPage: React.FC = () => { ...@@ -76,12 +86,18 @@ const WalletDetailPage: React.FC = () => {
}); });
}; };
const tabs = [
{ key: "info", label: t("sharedWallet.tabs.info") },
{ key: "members", label: t("sharedWallet.tabs.members") },
{ key: "analytics", label: t("sharedWallet.tabs.analytics") },
];
return ( return (
<Page className="page"> <Page className="page">
<Header title={t("wallet.detailHeader")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/wallets")} /> <Header title={t("wallet.detailHeader")} showBackIcon onBackClick={() => isFromReports(location.state) ? navigate(-1) : navigate("/wallets")} />
<IconGradients /> <IconGradients />
<main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-5 pb-12"> <main className="mx-auto mt-4 flex w-full max-w-lg flex-col gap-4 pb-12">
{walletQuery.isLoading && <><WalletSkeleton /><WalletSkeleton /></>} {walletQuery.isLoading && <><WalletSkeleton /><WalletSkeleton /></>}
{walletQuery.isError && ( {walletQuery.isError && (
...@@ -100,6 +116,7 @@ const WalletDetailPage: React.FC = () => { ...@@ -100,6 +116,7 @@ const WalletDetailPage: React.FC = () => {
{wallet && ( {wallet && (
<> <>
{/* Wallet Header Card */}
<Card className={`relative overflow-hidden p-6 ${wallet.isArchived ? "bg-clay-text-muted/10" : ""}`}> <Card className={`relative overflow-hidden p-6 ${wallet.isArchived ? "bg-clay-text-muted/10" : ""}`}>
<div className="absolute -right-8 -top-8 h-32 w-32 rounded-full opacity-10" style={{ backgroundColor: wallet.color || "#8B7CF6" }} /> <div className="absolute -right-8 -top-8 h-32 w-32 rounded-full opacity-10" style={{ backgroundColor: wallet.color || "#8B7CF6" }} />
<div className="relative flex flex-col items-center text-center"> <div className="relative flex flex-col items-center text-center">
...@@ -107,6 +124,7 @@ const WalletDetailPage: React.FC = () => { ...@@ -107,6 +124,7 @@ const WalletDetailPage: React.FC = () => {
<div className="mt-4 flex flex-wrap items-center justify-center gap-2"> <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<h1 className="clay-title-h2">{wallet.name}</h1> <h1 className="clay-title-h2">{wallet.name}</h1>
{wallet.isDefault && <Badge type="primary">{t("wallet.defaultWallet")}</Badge>} {wallet.isDefault && <Badge type="primary">{t("wallet.defaultWallet")}</Badge>}
{wallet.isShared && <SharedWalletBadge role={wallet.myRole} />}
{wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>} {wallet.isArchived && <Badge type="warning">{t("common.archived")}</Badge>}
</div> </div>
<p className={`mt-2 font-baloo text-3xl font-bold ${Number(wallet.balance) < 0 ? "text-clay-expense" : "text-clay-primary-dark"}`}> <p className={`mt-2 font-baloo text-3xl font-bold ${Number(wallet.balance) < 0 ? "text-clay-expense" : "text-clay-primary-dark"}`}>
...@@ -114,60 +132,139 @@ const WalletDetailPage: React.FC = () => { ...@@ -114,60 +132,139 @@ const WalletDetailPage: React.FC = () => {
</p> </p>
<p className="mt-1 font-nunito text-xs font-bold uppercase tracking-wider text-clay-text-muted">{wallet.currency}</p> <p className="mt-1 font-nunito text-xs font-bold uppercase tracking-wider text-clay-text-muted">{wallet.currency}</p>
{wallet.description && <p className="clay-body mt-4 max-w-sm text-sm">{wallet.description}</p>} {wallet.description && <p className="clay-body mt-4 max-w-sm text-sm">{wallet.description}</p>}
{isOwner && !wallet.isArchived && (
<div className="mt-4 flex items-center justify-center gap-2">
<Button
variant="primary"
onClick={() => setIsInviteOpen(true)}
className="gap-2 text-xs sm:text-sm !py-2 !px-4 shadow-clay-raised"
>
<QrCodeIcon size={18} />
<span>{t("sharedWallet.membersTab.createInviteBtn")}</span>
</Button>
</div>
)}
</div> </div>
</Card> </Card>
<Card className="p-5"> {/* Navigation Tabs */}
<h2 className="clay-title-h3 mb-4">{t("wallet.info")}</h2> <Tabs
<dl className="divide-y divide-clay-text-muted/10"> tabs={tabs}
<div className="flex justify-between gap-4 py-3"> activeTab={activeTab}
<dt className="clay-caption">{t("wallet.status")}</dt> onChange={setActiveTab}
<dd className="font-nunito text-sm font-bold text-clay-text">{wallet.isArchived ? t("common.archived") : t("common.active")}</dd> />
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">{t("wallet.createdAt")}</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.createdAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</div>
<div className="flex justify-between gap-4 py-3">
<dt className="clay-caption">{t("wallet.updatedAt")}</dt>
<dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.updatedAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</div>
</dl>
</Card>
<Card className="flex flex-col gap-3 p-5"> {/* Tab: Thông tin */}
<h2 className="clay-title-h3">{t("wallet.management")}</h2> {activeTab === "info" && (
{!wallet.isArchived && ( <>
<> {/* Invite Promo Card for unshared wallets if user is Owner */}
<Button fullWidth onClick={() => setIsEditOpen(true)}>{t("wallet.edit")}</Button> {!wallet.isShared && isOwner && (
{!wallet.isDefault && ( <Card className="flex items-center justify-between gap-3 !p-4 bg-clay-primary/10 border border-clay-primary/30 shadow-clay-raised">
<Button variant="secondary" fullWidth disabled={setDefaultMutation.isPending} onClick={handleSetDefault}> <div className="flex items-center gap-3">
{setDefaultMutation.isPending ? t("common.updating") : t("wallet.setDefault")} <div className="p-2.5 rounded-clay-sm bg-clay-primary text-clay-on-primary">
<UsersIcon size={22} />
</div>
<div>
<h4 className="font-baloo font-bold text-clay-text text-sm">
{t("sharedWallet.promo.title")}
</h4>
<p className="text-xs text-clay-text-muted">
{t("sharedWallet.promo.subtitle")}
</p>
</div>
</div>
<Button
variant="primary"
onClick={() => setIsInviteOpen(true)}
className="whitespace-nowrap !py-2 !px-3 text-xs flex items-center gap-1.5"
>
<QrCodeIcon size={16} />
<span>{t("sharedWallet.promo.createQrBtn")}</span>
</Button> </Button>
)} </Card>
<Button )}
variant="ghost"
fullWidth <Card className="p-5">
disabled={wallet.isDefault} <h2 className="clay-title-h3 mb-4">{t("wallet.info")}</h2>
onClick={() => setIsArchiveOpen(true)} <dl className="divide-y divide-clay-text-muted/10">
className="text-clay-expense" <div className="flex justify-between gap-4 py-3">
> <dt className="clay-caption">{t("sharedWallet.type.label")}</dt>
{t("wallet.archive")} <dd className="font-nunito text-sm font-bold text-clay-text">
</Button> {wallet.isShared ? t("sharedWallet.type.shared") : t("sharedWallet.type.personal")}
{wallet.isDefault && ( </dd>
<p className="clay-caption text-center">{t("wallet.archiveDefaultHint")}</p> </div>
)} <div className="flex justify-between gap-4 py-3">
</> <dt className="clay-caption">{t("wallet.status")}</dt>
)} <dd className="font-nunito text-sm font-bold text-clay-text">{wallet.isArchived ? t("common.archived") : t("common.active")}</dd>
{wallet.isArchived && ( </div>
<> <div className="flex justify-between gap-4 py-3">
<Button fullWidth disabled={restoreMutation.isPending} onClick={handleRestore}> <dt className="clay-caption">{t("wallet.createdAt")}</dt>
{restoreMutation.isPending ? t("wallet.restoring") : t("wallet.restore")} <dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.createdAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</Button> </div>
<p className="clay-caption text-center">{t("wallet.restoreHint")}</p> <div className="flex justify-between gap-4 py-3">
</> <dt className="clay-caption">{t("wallet.updatedAt")}</dt>
)} <dd className="text-right font-nunito text-sm font-semibold text-clay-text">{formatDate(wallet.updatedAt, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}</dd>
</Card> </div>
</dl>
</Card>
{isOwner && (
<Card className="flex flex-col gap-3 p-5">
<h2 className="clay-title-h3">{t("wallet.management")}</h2>
{!wallet.isArchived && (
<>
<Button fullWidth onClick={() => setIsEditOpen(true)}>{t("wallet.edit")}</Button>
{!wallet.isDefault && (
<Button variant="secondary" fullWidth disabled={setDefaultMutation.isPending} onClick={handleSetDefault}>
{setDefaultMutation.isPending ? t("common.updating") : t("wallet.setDefault")}
</Button>
)}
<Button
variant="ghost"
fullWidth
disabled={wallet.isDefault}
onClick={() => setIsArchiveOpen(true)}
className="text-clay-expense"
>
{t("wallet.archive")}
</Button>
{wallet.isDefault && (
<p className="clay-caption text-center">{t("wallet.archiveDefaultHint")}</p>
)}
</>
)}
{wallet.isArchived && (
<>
<Button fullWidth disabled={restoreMutation.isPending} onClick={handleRestore}>
{restoreMutation.isPending ? t("wallet.restoring") : t("wallet.restore")}
</Button>
<p className="clay-caption text-center">{t("wallet.restoreHint")}</p>
</>
)}
</Card>
)}
</>
)}
{/* Tab: Thành viên */}
{activeTab === "members" && (
<WalletMembersTab
walletId={walletId}
walletName={wallet.name}
myRole={wallet.myRole}
currency={wallet.currency}
onWalletLeft={() => navigate("/wallets")}
/>
)}
{/* Tab: Báo cáo quỹ */}
{activeTab === "analytics" && (
<SharedWalletAnalyticsCard
walletId={walletId}
currency={wallet.currency}
/>
)}
</> </>
)} )}
</main> </main>
...@@ -197,6 +294,16 @@ const WalletDetailPage: React.FC = () => { ...@@ -197,6 +294,16 @@ const WalletDetailPage: React.FC = () => {
> >
<p className="clay-body text-sm">{t("wallet.archiveDescription")}</p> <p className="clay-body text-sm">{t("wallet.archiveDescription")}</p>
</Modal> </Modal>
{/* Modal mời thành viên & hiển thị mã QR */}
{wallet && (
<InviteMemberModal
isOpen={isInviteOpen}
onClose={() => setIsInviteOpen(false)}
walletId={walletId}
walletName={wallet.name}
/>
)}
</Page> </Page>
); );
}; };
......
...@@ -14,6 +14,9 @@ import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wa ...@@ -14,6 +14,9 @@ import { SortOrder, WalletInput, WalletQuery, WalletSortField } from "@/types/wa
import { WalletFormModal } from "./components/WalletFormModal"; import { WalletFormModal } from "./components/WalletFormModal";
import { WalletSkeleton } from "./components/WalletSkeleton"; import { WalletSkeleton } from "./components/WalletSkeleton";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { JoinWalletModal } from "@/components/shared-wallets/JoinWalletModal";
import { InviteMemberModal } from "@/components/shared-wallets/InviteMemberModal";
import { UsersIcon } from "@/components/ui/icons";
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
...@@ -27,9 +30,13 @@ const WalletsPage: React.FC = () => { ...@@ -27,9 +30,13 @@ const WalletsPage: React.FC = () => {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase(intlLocale)); const deferredSearch = useDeferredValue(search.trim().toLocaleLowerCase(intlLocale));
const [includeArchived, setIncludeArchived] = useState(false); const [includeArchived, setIncludeArchived] = useState(false);
const [spendingScope, setSpendingScope] = useState<"ALL" | "PERSONAL" | "SHARED">("ALL");
const [sort, setSort] = useState("createdAt:desc"); const [sort, setSort] = useState("createdAt:desc");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isJoinOpen, setIsJoinOpen] = useState(false);
const [joinCode, setJoinCode] = useState("");
const [sharingWallet, setSharingWallet] = useState<{ id: string; name: string } | null>(null);
const sortOptions = useMemo(() => [ const sortOptions = useMemo(() => [
{ value: "createdAt:desc", label: t("wallet.sort.createdDesc") }, { value: "createdAt:desc", label: t("wallet.sort.createdDesc") },
...@@ -44,11 +51,12 @@ const WalletsPage: React.FC = () => { ...@@ -44,11 +51,12 @@ const WalletsPage: React.FC = () => {
const query = useMemo<WalletQuery>(() => ({ const query = useMemo<WalletQuery>(() => ({
includeArchived, includeArchived,
type: "STANDARD", type: "STANDARD",
spendingScope: spendingScope === "ALL" ? undefined : spendingScope,
sortBy, sortBy,
order, order,
page, page,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}), [includeArchived, order, page, sortBy]); }), [includeArchived, order, page, sortBy, spendingScope]);
const summaryQuery = useMemo<WalletQuery>(() => ({ const summaryQuery = useMemo<WalletQuery>(() => ({
includeArchived: false, includeArchived: false,
...@@ -71,7 +79,7 @@ const WalletsPage: React.FC = () => { ...@@ -71,7 +79,7 @@ const WalletsPage: React.FC = () => {
useEffect(() => { useEffect(() => {
setPage(1); setPage(1);
}, [deferredSearch, includeArchived, sort]); }, [deferredSearch, includeArchived, sort, spendingScope]);
const filteredWallets = useMemo(() => { const filteredWallets = useMemo(() => {
const wallets = walletsQuery.data?.data || []; const wallets = walletsQuery.data?.data || [];
...@@ -157,14 +165,51 @@ const WalletsPage: React.FC = () => { ...@@ -157,14 +165,51 @@ const WalletsPage: React.FC = () => {
</Card> </Card>
{/* Action Header */} {/* Action Header */}
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3 flex-wrap">
<div> <div>
<h1 className="clay-title-h2">{t("wallet.list")}</h1> <h1 className="clay-title-h2">{t("wallet.list")}</h1>
<p className="clay-caption">{walletsQuery.isFetching ? t("wallet.syncing") : t("common.results", { count: totalItems })}</p> <p className="clay-caption">{walletsQuery.isFetching ? t("wallet.syncing") : t("common.results", { count: totalItems })}</p>
</div> </div>
<Button shape="pill" className="gap-2 px-4 text-sm" onClick={() => setIsCreateOpen(true)}> <div className="flex items-center gap-2">
<PlusIcon size={18} /> {t("wallet.create")} <Button
</Button> variant="secondary"
shape="pill"
className="gap-1.5 px-3.5 py-2 text-sm"
onClick={() => {
setJoinCode("");
setIsJoinOpen(true);
}}
>
<UsersIcon size={16} /> {t("sharedWallet.joinModal.button")}
</Button>
<Button shape="pill" className="gap-2 px-4 py-2 text-sm" onClick={() => setIsCreateOpen(true)}>
<PlusIcon size={18} /> {t("wallet.create")}
</Button>
</div>
</div>
{/* Scope Tabs */}
<div className="grid grid-cols-3 gap-2 p-1.5 rounded-clay bg-clay-surface border border-clay-border/80 shadow-clay-pressed">
{(
[
{ key: "ALL", label: t("sharedWallet.scope.all") },
{ key: "PERSONAL", label: t("sharedWallet.scope.personal") },
{ key: "SHARED", label: t("sharedWallet.scope.shared") },
] as const
).map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setSpendingScope(tab.key)}
className={`py-2 text-xs font-nunito font-bold rounded-clay-sm transition-all duration-200 ${
spendingScope === tab.key
? "bg-clay-primary text-clay-on-primary shadow-clay-raised"
: "text-clay-text-muted hover:text-clay-text"
}`}
>
{tab.label}
</button>
))}
</div> </div>
{/* Search & Sort Filters */} {/* Search & Sort Filters */}
...@@ -231,6 +276,7 @@ const WalletsPage: React.FC = () => { ...@@ -231,6 +276,7 @@ const WalletsPage: React.FC = () => {
onClick={() => navigate(`/wallets/${wallet.id}`)} onClick={() => navigate(`/wallets/${wallet.id}`)}
onSetDefault={() => handleSetDefault(wallet.id)} onSetDefault={() => handleSetDefault(wallet.id)}
isSettingDefault={setDefaultMutation.isPending && setDefaultMutation.variables === wallet.id} isSettingDefault={setDefaultMutation.isPending && setDefaultMutation.variables === wallet.id}
onShare={(w) => setSharingWallet({ id: w.id, name: w.name })}
/> />
))} ))}
</div> </div>
...@@ -253,6 +299,27 @@ const WalletsPage: React.FC = () => { ...@@ -253,6 +299,27 @@ const WalletsPage: React.FC = () => {
onClose={() => setIsCreateOpen(false)} onClose={() => setIsCreateOpen(false)}
onSubmit={handleCreate} onSubmit={handleCreate}
/> />
{/* Modal mời thành viên & tạo QR */}
{sharingWallet && (
<InviteMemberModal
isOpen={Boolean(sharingWallet)}
onClose={() => setSharingWallet(null)}
walletId={sharingWallet.id}
walletName={sharingWallet.name}
/>
)}
{/* Modal tham gia ví dùng chung */}
<JoinWalletModal
isOpen={isJoinOpen}
initialCode={joinCode}
onClose={() => {
setIsJoinOpen(false);
setJoinCode("");
}}
onSuccess={(id) => navigate(`/wallets/${id}`)}
/>
</Page> </Page>
); );
}; };
......
import { apiClient } from "@/lib/api-client";
import {
AcceptInvitationInput,
CreateInvitationInput,
InvitationPreview,
SharedWalletAnalytics,
UpdateMemberInput,
WalletInvitation,
WalletMember,
} from "@/types/shared-wallet";
interface ApiResponse<T> {
success: boolean;
message?: string;
data: T;
errors?: unknown[] | null;
}
function ensureSuccess<T extends { success: boolean; message?: string }>(response: T): T {
if (!response.success) {
throw new Error(response.message || "Shared wallet request failed");
}
return response;
}
export const sharedWalletService = {
async createInvitation(
walletId: string,
input?: CreateInvitationInput,
): Promise<ApiResponse<WalletInvitation>> {
const response = await apiClient.post<ApiResponse<WalletInvitation>>(
`/wallets/${walletId}/invitations`,
input || {},
);
return ensureSuccess(response.data);
},
async getInvitations(walletId: string): Promise<ApiResponse<WalletInvitation[]>> {
const response = await apiClient.get<ApiResponse<WalletInvitation[]>>(
`/wallets/${walletId}/invitations`,
);
return ensureSuccess(response.data);
},
async cancelInvitation(
walletId: string,
inviteId: string,
): Promise<ApiResponse<{ id: string }>> {
const response = await apiClient.delete<ApiResponse<{ id: string }>>(
`/wallets/${walletId}/invitations/${inviteId}`,
);
return ensureSuccess(response.data);
},
async previewInvitation(code: string): Promise<ApiResponse<InvitationPreview>> {
const response = await apiClient.get<ApiResponse<InvitationPreview>>(
`/wallets/invitations/preview/${encodeURIComponent(code)}`,
);
return ensureSuccess(response.data);
},
async acceptInvitation(
input: AcceptInvitationInput,
): Promise<ApiResponse<WalletMember>> {
const response = await apiClient.post<ApiResponse<WalletMember>>(
"/wallets/invitations/accept",
input,
);
return ensureSuccess(response.data);
},
async getMembers(walletId: string): Promise<ApiResponse<WalletMember[]>> {
const response = await apiClient.get<ApiResponse<WalletMember[]>>(
`/wallets/${walletId}/members`,
);
return ensureSuccess(response.data);
},
async updateMember(
walletId: string,
memberId: string,
input: UpdateMemberInput,
): Promise<ApiResponse<WalletMember>> {
const response = await apiClient.put<ApiResponse<WalletMember>>(
`/wallets/${walletId}/members/${memberId}`,
input,
);
return ensureSuccess(response.data);
},
async removeMember(
walletId: string,
memberId: string,
): Promise<ApiResponse<{ id: string }>> {
const response = await apiClient.delete<ApiResponse<{ id: string }>>(
`/wallets/${walletId}/members/${memberId}`,
);
return ensureSuccess(response.data);
},
async leaveWallet(walletId: string): Promise<ApiResponse<{ id: string }>> {
const response = await apiClient.post<ApiResponse<{ id: string }>>(
`/wallets/${walletId}/members/leave`,
);
return ensureSuccess(response.data);
},
async getAnalytics(
walletId: string,
query?: { from?: string; to?: string },
): Promise<ApiResponse<SharedWalletAnalytics>> {
const response = await apiClient.get<ApiResponse<SharedWalletAnalytics>>(
`/wallets/${walletId}/analytics`,
{ params: query },
);
return ensureSuccess(response.data);
},
};
...@@ -50,6 +50,7 @@ export interface Budget { ...@@ -50,6 +50,7 @@ export interface Budget {
type: BudgetType; type: BudgetType;
period: BudgetPeriod; period: BudgetPeriod;
categoryId: string | null; categoryId: string | null;
walletId?: string | null;
startDate: string; startDate: string;
endDate: string; endDate: string;
alertThreshold: string; alertThreshold: string;
...@@ -74,6 +75,7 @@ export interface BudgetQuery { ...@@ -74,6 +75,7 @@ export interface BudgetQuery {
period?: BudgetPeriod; period?: BudgetPeriod;
currency?: string; currency?: string;
categoryId?: string; categoryId?: string;
walletId?: string;
activeAt?: string; activeAt?: string;
includeArchived: boolean; includeArchived: boolean;
isRecurring?: boolean; isRecurring?: boolean;
...@@ -90,6 +92,7 @@ export interface CreateBudgetInput { ...@@ -90,6 +92,7 @@ export interface CreateBudgetInput {
type: BudgetType; type: BudgetType;
period: BudgetPeriod; period: BudgetPeriod;
categoryId?: string | null; categoryId?: string | null;
walletId?: string | null;
startDate: string; startDate: string;
endDate?: string; endDate?: string;
alertThreshold: string; alertThreshold: string;
......
...@@ -12,6 +12,7 @@ export interface ReportQuery { ...@@ -12,6 +12,7 @@ export interface ReportQuery {
dateTo?: string; dateTo?: string;
walletId?: string; walletId?: string;
currency?: string; currency?: string;
spendingScope?: "ALL" | "PERSONAL" | "SHARED";
granularity: ReportGranularity; granularity: ReportGranularity;
} }
......
export type WalletMemberRole = 'OWNER' | 'MEMBER' | 'VIEWER';
export type WalletMemberStatus = 'ACTIVE' | 'LEFT' | 'REMOVED';
export type WalletInvitationStatus = 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'REVOKED';
export type SpendingScope = 'ALL' | 'PERSONAL' | 'SHARED';
export interface WalletMemberUser {
id: string;
fullName: string | null;
avatarUrl: string | null;
email?: string | null;
phoneNumber?: string | null;
}
export interface WalletMember {
id: string;
walletId: string;
userId: string;
role: WalletMemberRole;
nickname: string | null;
status: WalletMemberStatus;
monthlySpendingLimit: string | null;
currentMonthSpent: string;
remainingLimit: string | null;
joinedAt: string;
user: WalletMemberUser;
}
export interface WalletInvitation {
id: string;
walletId: string;
inviterId: string;
role: WalletMemberRole;
inviteCode: string;
qrCodeDataUrl: string;
status: WalletInvitationStatus;
expiresAt: string;
createdAt: string;
wallet?: {
id: string;
name: string;
};
inviter?: {
fullName: string | null;
};
}
export interface InvitationPreview {
walletId: string;
walletName: string;
inviterName: string;
inviterAvatar: string | null;
role: WalletMemberRole;
activeMemberCount: number;
expiresAt: string;
}
export interface SharedWalletMemberAnalytics {
userId: string;
name?: string;
fullName?: string | null;
avatarUrl: string | null;
nickname?: string | null;
role: WalletMemberRole;
totalSpent: string;
spentAmount?: string;
percentage?: number;
spendingPercentage: string;
transactionCount: number;
monthlySpendingLimit?: string | null;
monthlyLimit?: string | null;
limitUsagePercentage?: number | null;
}
export interface SharedWalletSettlementItem {
userId: string;
fullName: string | null;
totalInjected: string;
totalSpent: string;
netBalance: string;
}
export interface SharedWalletAnalytics {
walletId: string;
walletName: string;
currency: string;
period?: { from: string; to: string };
periodFrom: string;
periodTo: string;
totalSpent: string;
totalExpense?: string;
totalIncome: string;
memberBreakdown: SharedWalletMemberAnalytics[];
settlementSummary?: SharedWalletSettlementItem[];
}
export interface CreateInvitationInput {
role?: WalletMemberRole;
expiresInDays?: number;
}
export interface AcceptInvitationInput {
inviteCode: string;
nickname?: string;
}
export interface UpdateMemberInput {
role?: WalletMemberRole;
nickname?: string | null;
monthlySpendingLimit?: string | null;
}
...@@ -38,6 +38,12 @@ export interface Transaction { ...@@ -38,6 +38,12 @@ export interface Transaction {
updatedAt: string; updatedAt: string;
wallet: TransactionWallet; wallet: TransactionWallet;
category: TransactionCategory; category: TransactionCategory;
createdBy?: {
id: string;
fullName: string | null;
avatarUrl: string | null;
} | null;
isSharedWallet?: boolean;
} }
export interface TransactionQuery { export interface TransactionQuery {
...@@ -45,6 +51,7 @@ export interface TransactionQuery { ...@@ -45,6 +51,7 @@ export interface TransactionQuery {
walletId?: string; walletId?: string;
categoryId?: string; categoryId?: string;
type?: TransactionType; type?: TransactionType;
spendingScope?: "ALL" | "PERSONAL" | "SHARED";
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
minAmount?: string; minAmount?: string;
......
...@@ -15,6 +15,22 @@ export interface Wallet { ...@@ -15,6 +15,22 @@ export interface Wallet {
description: string | null; description: string | null;
isDefault: boolean; isDefault: boolean;
isArchived: boolean; isArchived: boolean;
isShared?: boolean;
myRole?: "OWNER" | "MEMBER" | "VIEWER";
membersSummary?: {
totalActiveMembers: number;
members: Array<{
id: string;
userId: string;
role: "OWNER" | "MEMBER" | "VIEWER";
nickname: string | null;
user: {
id: string;
fullName: string | null;
avatarUrl: string | null;
};
}>;
};
termDepositId?: string | null; termDepositId?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
...@@ -23,6 +39,7 @@ export interface Wallet { ...@@ -23,6 +39,7 @@ export interface Wallet {
export interface WalletQuery { export interface WalletQuery {
includeArchived: boolean; includeArchived: boolean;
type?: WalletType; type?: WalletType;
spendingScope?: "ALL" | "PERSONAL" | "SHARED";
sortBy: WalletSortField; sortBy: WalletSortField;
order: SortOrder; order: SortOrder;
page: number; page: number;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment