Skip to content

Commit 7a93bc2

Browse files
feat: add sheet archival
1 parent 331d899 commit 7a93bc2

File tree

9 files changed

+269
-39
lines changed

9 files changed

+269
-39
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "sheets"
3+
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;

packages/backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ model Sheet {
5050
type SheetType
5151
name String
5252
currencyCode String @map("currency_code")
53+
isArchived Boolean @default(false) @map("is_archived")
5354
5455
participants SheetMemberships[]
5556
transactions Transaction[]

packages/backend/src/service/sheet/router.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ describe('createPersonalSheet', () => {
2828
type: 'PERSONAL',
2929
name: 'Personal Expenses',
3030
currencyCode: 'EUR',
31+
isArchived: false,
3132
});
3233
});
3334
});
@@ -50,6 +51,7 @@ describe('createGroupSheet', () => {
5051
type: 'GROUP',
5152
name: 'WG Expenses',
5253
currencyCode: 'EUR',
54+
isArchived: false,
5355
participants: expect.arrayContaining([
5456
{
5557
id: user.id,
@@ -98,6 +100,7 @@ describe('personalSheetById', () => {
98100
type: 'PERSONAL',
99101
name: personalSheet.name,
100102
currencyCode: personalSheet.currencyCode,
103+
isArchived: false,
101104
});
102105
});
103106

@@ -149,6 +152,7 @@ describe('groupSheetById', () => {
149152
type: 'GROUP',
150153
name: groupSheet.name,
151154
currencyCode: groupSheet.currencyCode,
155+
isArchived: false,
152156
participants: [
153157
{
154158
id: user.id,
@@ -283,6 +287,53 @@ describe('deleteSheet', () => {
283287
});
284288
});
285289

290+
describe('archiveSheet', () => {
291+
describe.each([
292+
['personalSheet', personalSheetFactory],
293+
['groupSheet', groupSheetFactory],
294+
])('%s', (sheetType, factory) => {
295+
it(`archives a ${sheetType}`, async () => {
296+
const user = await userFactory(prisma);
297+
const caller = useProtectedCaller(user);
298+
const sheet = await factory(prisma, {
299+
withOwnerId: user.id,
300+
});
301+
302+
await caller.sheet.archiveSheet(sheet.id);
303+
304+
expect(
305+
await prisma.sheet.findUnique({ where: { id: sheet.id } }),
306+
).toMatchObject({ isArchived: true });
307+
});
308+
309+
it.todo(`archives a ${sheetType} with transactions`);
310+
311+
it('returns a 404 if the participant has no access', async () => {
312+
const user = await userFactory(prisma);
313+
const caller = useProtectedCaller(user);
314+
const sheet = await factory(prisma);
315+
316+
await expect(caller.sheet.archiveSheet(sheet.id)).rejects.toThrow(
317+
'Sheet not found',
318+
);
319+
});
320+
321+
if (sheetType === 'groupSheet') {
322+
it('returns a 403 if the participant is not an admin', async () => {
323+
const user = await userFactory(prisma);
324+
const caller = useProtectedCaller(user);
325+
const sheet = await factory(prisma, {
326+
withParticipantIds: [user.id],
327+
});
328+
329+
await expect(caller.sheet.archiveSheet(sheet.id)).rejects.toThrow(
330+
'Only admins can archive sheets',
331+
);
332+
});
333+
}
334+
});
335+
});
336+
286337
describe('addGroupSheetMember', () => {
287338
it('adds a member', async () => {
288339
const user = await userFactory(prisma);

packages/backend/src/service/sheet/router.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,25 @@ export const sheetRouter = router({
9494
};
9595
}),
9696

97+
archiveSheet: protectedProcedure
98+
.input(z.string().nonempty())
99+
.output(z.void())
100+
.mutation(async ({ input, ctx }) => {
101+
const { role } = await ctx.sheetService.ensureSheetMembership(
102+
input,
103+
ctx.user.id,
104+
);
105+
106+
if (role !== SheetParticipantRole.ADMIN) {
107+
throw new TRPCError({
108+
code: 'FORBIDDEN',
109+
message: 'Only admins can archive sheets',
110+
});
111+
}
112+
113+
await ctx.sheetService.archiveSheet(input);
114+
}),
115+
97116
deleteSheet: protectedProcedure
98117
.input(z.string().nonempty())
99118
.output(z.void())

packages/backend/src/service/sheet/service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,17 @@ export class SheetService {
165165
});
166166
}
167167

168+
async archiveSheet(id: string) {
169+
return this.prismaClient.sheet.update({
170+
where: {
171+
id,
172+
},
173+
data: {
174+
isArchived: true,
175+
},
176+
});
177+
}
178+
168179
async addGroupSheetMember(groupSheet: Sheet, participantEmail: string) {
169180
try {
170181
const member = await this.prismaClient.sheetMemberships.create({
Lines changed: 115 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,124 @@
1+
import { AnimatePresence } from 'framer-motion';
2+
import { motion } from 'framer-motion';
3+
import { useMemo, useState } from 'react';
14
import { MdGroup, MdListAlt } from 'react-icons/md';
25
import { Link } from 'react-router-dom';
36

47
import type { SheetsResponse } from '@nihalgonsalves/expenses-shared/types/sheet';
58

9+
import { collapse } from '../utils/framer';
10+
import { clsxtw } from '../utils/utils';
11+
612
import { Avatar } from './Avatar';
13+
import { ExpandMoreButton } from './ExpandMoreButton';
714

8-
export const SheetsList = ({ sheets }: { sheets: SheetsResponse }) => (
9-
<div className="flex flex-col gap-4">
10-
{sheets.length === 0 && <div className="alert">No sheets</div>}
11-
{sheets.map((sheet) => (
12-
<div key={sheet.id} className="flex items-center gap-4 h-14">
13-
<Link
14-
className="btn btn-ghost no-animation flex-grow justify-start gap-4 text-start text-xl normal-case text-primary"
15-
to={
16-
sheet.type === 'PERSONAL'
17-
? `/sheets/${sheet.id}`
18-
: `/groups/${sheet.id}`
19-
}
20-
>
21-
{sheet.type === 'PERSONAL' ? <MdListAlt /> : <MdGroup />}
22-
{sheet.name}
23-
</Link>
24-
{sheet.type === 'GROUP' && (
25-
<div className="avatar-group -space-x-6">
26-
{sheet.participants.map((participant) => (
27-
<Avatar key={participant.id} name={participant.name} />
15+
const partitionSheets = (sheets: SheetsResponse) => {
16+
const personal: SheetsResponse = [];
17+
const group: SheetsResponse = [];
18+
const archived: SheetsResponse = [];
19+
20+
for (const sheet of sheets) {
21+
if (sheet.isArchived) {
22+
archived.push(sheet);
23+
} else if (sheet.type === 'PERSONAL') {
24+
personal.push(sheet);
25+
} else {
26+
group.push(sheet);
27+
}
28+
}
29+
30+
return { personal, group, archived };
31+
};
32+
33+
const SheetItem = ({ sheet }: { sheet: SheetsResponse[0] }) => {
34+
const link =
35+
sheet.type === 'PERSONAL' ? `/sheets/${sheet.id}` : `/groups/${sheet.id}`;
36+
37+
return (
38+
<div key={sheet.id} className="flex items-center gap-4 h-14">
39+
<Link
40+
className="btn btn-ghost no-animation flex-grow justify-start gap-4 text-start text-xl normal-case text-primary"
41+
to={link}
42+
>
43+
{sheet.type === 'PERSONAL' ? <MdListAlt /> : <MdGroup />}
44+
{sheet.name}
45+
</Link>
46+
{sheet.type === 'GROUP' && (
47+
<div className="avatar-group -space-x-6">
48+
{sheet.participants.map((participant) => (
49+
<Avatar key={participant.id} name={participant.name} />
50+
))}
51+
</div>
52+
)}
53+
</div>
54+
);
55+
};
56+
57+
export const SheetsList = ({ sheets }: { sheets: SheetsResponse }) => {
58+
const { personal, group, archived } = useMemo(
59+
() => partitionSheets(sheets),
60+
[sheets],
61+
);
62+
63+
const [showArchived, setShowArchived] = useState(false);
64+
65+
return sheets.length === 0 ? (
66+
<div className="flex flex-col gap-4">
67+
<div className="alert">No sheets</div>
68+
</div>
69+
) : (
70+
<div className="flex flex-col md:grid md:grid-cols-2 gap-4">
71+
<div className="flex flex-col flex-grow gap-4 card card-compact card-bordered">
72+
<div className="card-body">
73+
<h2 className="card-title">Personal Sheets</h2>
74+
{personal.map((sheet) => (
75+
<SheetItem key={sheet.id} sheet={sheet} />
76+
))}
77+
</div>
78+
</div>
79+
80+
{group.length > 0 && (
81+
<div className="flex flex-col flex-grow gap-4 card card-compact card-bordered">
82+
<div className="card-body">
83+
<h2 className="card-title">Group Sheets</h2>
84+
85+
{group.map((sheet) => (
86+
<SheetItem key={sheet.id} sheet={sheet} />
2887
))}
2988
</div>
30-
)}
31-
</div>
32-
))}
33-
</div>
34-
);
89+
</div>
90+
)}
91+
92+
{archived.length > 0 && (
93+
<div
94+
className={clsxtw(
95+
'flex flex-col flex-grow gap-4 card card-compact card-bordered',
96+
showArchived ? '' : 'opacity-50',
97+
)}
98+
>
99+
<div className="card-body">
100+
<h2 className="card-title flex justify-between">
101+
Archived Sheets
102+
<ExpandMoreButton
103+
expand={showArchived}
104+
onClick={() => {
105+
setShowArchived((prev) => !prev);
106+
}}
107+
/>
108+
</h2>
109+
110+
<AnimatePresence initial={false}>
111+
{showArchived && (
112+
<motion.div {...collapse}>
113+
{archived.map((sheet) => (
114+
<SheetItem key={sheet.id} sheet={sheet} />
115+
))}
116+
</motion.div>
117+
)}
118+
</AnimatePresence>
119+
</div>
120+
</div>
121+
)}
122+
</div>
123+
);
124+
};

packages/frontend/src/pages/groups/GroupDetailPage.tsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { useCallback, useMemo } from 'react';
2-
import { MdDeleteOutline, MdMoreVert, MdPlaylistAdd } from 'react-icons/md';
2+
import {
3+
MdDeleteOutline,
4+
MdMoreVert,
5+
MdOutlineArchive,
6+
MdPlaylistAdd,
7+
} from 'react-icons/md';
38
import { useNavigate } from 'react-router-dom';
49

510
import { trpc } from '../../api/trpc';
@@ -23,6 +28,7 @@ export const GroupDetailPage = () => {
2328

2429
const { mutateAsync: deleteGroupSheet } =
2530
trpc.sheet.deleteSheet.useMutation();
31+
const { mutateAsync: archiveSheet } = trpc.sheet.archiveSheet.useMutation();
2632

2733
const handleDelete = useCallback(async () => {
2834
await deleteGroupSheet(groupSheetId);
@@ -33,6 +39,13 @@ export const GroupDetailPage = () => {
3339
navigate('/groups');
3440
}, [deleteGroupSheet, groupSheetId, navigate, utils]);
3541

42+
const handleArchive = useCallback(async () => {
43+
await archiveSheet(groupSheetId);
44+
void utils.sheet.groupSheetById.invalidate(groupSheetId);
45+
void utils.sheet.mySheets.invalidate();
46+
navigate('/sheets');
47+
}, [archiveSheet, groupSheetId, navigate, utils]);
48+
3649
const actorInfo: ActorInfo | undefined = useMemo(
3750
() =>
3851
me && result.data
@@ -64,18 +77,33 @@ export const GroupDetailPage = () => {
6477
<ExportGroupTransactionsDropdown groupSheet={result.data} />
6578
)}
6679
{actorInfo?.isAdmin && (
67-
<ConfirmDialog
68-
confirmLabel="Confirm Delete"
69-
description="Are you sure you want to delete this group? This action is irreversible."
70-
onConfirm={handleDelete}
71-
renderButton={(onClick) => (
72-
<li>
73-
<a onClick={onClick}>
74-
<MdDeleteOutline /> Delete Group
75-
</a>
76-
</li>
77-
)}
78-
/>
80+
<>
81+
<ConfirmDialog
82+
confirmLabel="Confirm Delete"
83+
description="Are you sure you want to delete this group? This action is irreversible."
84+
onConfirm={handleDelete}
85+
renderButton={(onClick) => (
86+
<li>
87+
<a onClick={onClick}>
88+
<MdDeleteOutline /> Delete Group
89+
</a>
90+
</li>
91+
)}
92+
/>
93+
94+
<ConfirmDialog
95+
confirmLabel="Confirm Archive"
96+
description="Are you sure you want to archive this sheet?"
97+
onConfirm={handleArchive}
98+
renderButton={(onClick) => (
99+
<li>
100+
<a onClick={onClick}>
101+
<MdOutlineArchive /> Archive Sheet
102+
</a>
103+
</li>
104+
)}
105+
/>
106+
</>
79107
)}
80108
</DropdownMenu>
81109
}

0 commit comments

Comments
 (0)