diff --git a/.prettierrc.json b/.prettierrc.json
new file mode 100644
index 0000000..91f032a
--- /dev/null
+++ b/.prettierrc.json
@@ -0,0 +1,14 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "arrowParens": "always",
+ "bracketSpacing": true,
+ "semi": true,
+ "tabWidth": 2,
+ "useTabs": false,
+ "printWidth": 120,
+ "endOfLine": "lf",
+ "quoteProps": "as-needed",
+ "jsxSingleQuote": true,
+ "jsxBracketSameLine": true
+}
diff --git a/package-lock.json b/package-lock.json
index d41445f..c16c628 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,8 +9,10 @@
"version": "0.1.0",
"dependencies": {
"next": "16.0.1",
+ "pocketbase": "^0.26.3",
"react": "19.2.0",
- "react-dom": "19.2.0"
+ "react-dom": "19.2.0",
+ "zustand": "^5.0.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1023,7 +1025,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1079,7 +1081,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
@@ -1498,6 +1500,12 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
+ "node_modules/pocketbase": {
+ "version": "0.26.3",
+ "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.26.3.tgz",
+ "integrity": "sha512-5deUKRoEczpxxuHzwr6/DHVmgbggxylEVig8CKN+MjvtYxPUqX/C6puU0yaR2yhTi8zrh7J9s7Ty+qBGwVzWOQ==",
+ "license": "MIT"
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -1691,6 +1699,35 @@
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
+ "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 34c4478..d48593a 100644
--- a/package.json
+++ b/package.json
@@ -8,17 +8,19 @@
"start": "next start"
},
"dependencies": {
+ "next": "16.0.1",
+ "pocketbase": "^0.26.3",
"react": "19.2.0",
"react-dom": "19.2.0",
- "next": "16.0.1"
+ "zustand": "^5.0.8"
},
"devDependencies": {
- "babel-plugin-react-compiler": "1.0.0",
- "typescript": "^5",
+ "@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
- "@tailwindcss/postcss": "^4",
- "tailwindcss": "^4"
+ "babel-plugin-react-compiler": "1.0.0",
+ "tailwindcss": "^4",
+ "typescript": "^5"
}
}
diff --git a/src/app/globals.css b/src/app/globals.css
index a2dc41e..1f756a4 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,26 +1,5 @@
@import "tailwindcss";
-:root {
- --background: #ffffff;
- --foreground: #171717;
-}
-
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
-}
-
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ @apply bg-gray-50 text-gray-800 font-sans antialiased;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index f7fa87e..a1fce86 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,20 +1,9 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
-import "./globals.css";
-
-const geistSans = Geist({
- variable: "--font-geist-sans",
- subsets: ["latin"],
-});
-
-const geistMono = Geist_Mono({
- variable: "--font-geist-mono",
- subsets: ["latin"],
-});
+import type { Metadata } from 'next';
+import './globals.css';
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app",
+ title: 'Gift Tracker',
+ description: 'Track and manage your gifts',
};
export default function RootLayout({
@@ -23,11 +12,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
+
+
+
);
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 295f8fd..f3726c0 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,65 +1,20 @@
-import Image from "next/image";
+import { YearOverview } from '@/components/organisms/YearOverview';
+import { YearList } from '@/components/atoms/YearList';
+import { DB } from '@/lib/db';
+import { GiftCard } from '@/components/molecules/GiftCard';
+import { Heading } from '@/components/atoms/Heading';
+
+export default async function Home() {
+ const recentGifts = await DB.getRecentGifts();
-export default function Home() {
return (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
+ <>
+
+
+ Ostatnie prezenty
+ {recentGifts.map((gift) => (
+
+ ))}
+ >
);
}
diff --git a/src/app/store/global.tsx b/src/app/store/global.tsx
new file mode 100644
index 0000000..59097d3
--- /dev/null
+++ b/src/app/store/global.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { useEffect } from 'react';
+import { create } from 'zustand';
+
+type Props = {
+ year: Year;
+ persons: Person[];
+ gifts: Gift[];
+};
+
+type State = Props & {
+ hydrate: (data: Partial) => void;
+};
+
+export const useGlobalStore = create((set) => ({
+ year: {} as Year,
+ persons: [] as Person[],
+ gifts: [] as Gift[],
+
+ hydrate: (data: Partial) => set(data),
+}));
+
+export const GlobalStore = ({ children, year, persons, gifts }: Partial & { children: React.ReactNode }) => {
+ const hydrate = useGlobalStore((s) => s.hydrate);
+
+ useEffect(() => {
+ hydrate({ year, persons, gifts });
+ }, []);
+
+ return children;
+};
diff --git a/src/app/year/[year]/page.tsx b/src/app/year/[year]/page.tsx
new file mode 100644
index 0000000..874d354
--- /dev/null
+++ b/src/app/year/[year]/page.tsx
@@ -0,0 +1,24 @@
+import { YearOverview } from '@/components/organisms/YearOverview';
+import { PersonCard } from '@/components/molecules/PersonCard';
+import { YearList } from '@/components/atoms/YearList';
+import { DB } from '@/lib/db';
+import { YearControls } from '@/components/organisms/YearControls';
+import { GlobalStore } from '@/app/store/global';
+
+export default async function YearPage({ params }: { params: Promise<{ year: string }> }) {
+ const { year } = await params;
+
+ const data = await DB.getYear(Number(year));
+ const persons = await DB.getPersons(data.id);
+
+ return (
+
+
+
+
+ {persons.map((person) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/atoms/Badge/index.tsx b/src/components/atoms/Badge/index.tsx
new file mode 100644
index 0000000..9665b00
--- /dev/null
+++ b/src/components/atoms/Badge/index.tsx
@@ -0,0 +1,11 @@
+import { FC } from 'react';
+
+type Props = {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+};
+
+export const Badge: FC = ({ children, onClick, disabled }) => {
+ return {children};
+};
diff --git a/src/components/atoms/Button/index.tsx b/src/components/atoms/Button/index.tsx
new file mode 100644
index 0000000..bdc4bcd
--- /dev/null
+++ b/src/components/atoms/Button/index.tsx
@@ -0,0 +1,34 @@
+import { FC } from 'react';
+import Link from 'next/link';
+
+type Props = {
+ children: React.ReactNode;
+ onClick?: (e: React.MouseEvent) => void;
+ disabled?: boolean;
+ variant?: keyof typeof variants;
+ href?: string;
+};
+export const Button: FC = ({ children, onClick, disabled, variant = 'primary', href }) => {
+ if (href) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+const variants = {
+ primary:
+ 'px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed',
+ secondary:
+ 'px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-100 rounded-xl transition-all duration-200 ease-in-out',
+ danger:
+ 'px-4 py-2 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-xl transition-all duration-200 ease-in-out disabled:opacity-70 disabled:cursor-not-allowed',
+};
diff --git a/src/components/atoms/Heading/index.tsx b/src/components/atoms/Heading/index.tsx
new file mode 100644
index 0000000..eaf63a2
--- /dev/null
+++ b/src/components/atoms/Heading/index.tsx
@@ -0,0 +1,22 @@
+import { FC } from 'react';
+
+type Props = {
+ size?: 'small' | 'medium' | 'large';
+ children: React.ReactNode;
+ spacing?: 'none' | 'small' | 'medium' | 'large';
+};
+
+export const Heading: FC = ({ children, size = 'medium', spacing = 'medium' }) => {
+ const sizeClass = {
+ small: 'text-lg',
+ medium: 'text-2xl',
+ large: 'text-3xl',
+ };
+ const spacingClass = {
+ none: 'mb-0',
+ small: 'mb-2',
+ medium: 'mb-4',
+ large: 'mb-6',
+ };
+ return {children}
;
+};
diff --git a/src/components/atoms/Modal/index.tsx b/src/components/atoms/Modal/index.tsx
new file mode 100644
index 0000000..5a58d08
--- /dev/null
+++ b/src/components/atoms/Modal/index.tsx
@@ -0,0 +1,46 @@
+'use client';
+import { FC, ReactNode, useEffect } from 'react';
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ title: string;
+ children: ReactNode;
+ footer?: ReactNode;
+};
+
+export const Modal: FC = ({ isOpen, onClose, title, children, footer }) => {
+ useEffect(() => {
+ if (isOpen) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
{title}
+
+
+
{children}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+};
diff --git a/src/components/atoms/StatsCard/index.tsx b/src/components/atoms/StatsCard/index.tsx
new file mode 100644
index 0000000..bb1ff64
--- /dev/null
+++ b/src/components/atoms/StatsCard/index.tsx
@@ -0,0 +1,17 @@
+import { FC } from 'react';
+
+type Props = {
+ title: string;
+ value: number | string;
+ description?: string;
+};
+
+export const StatsCard: FC = ({ title, value, description }) => {
+ return (
+
+
{title}
+
{value}
+ {description &&
{description}
}
+
+ );
+};
diff --git a/src/components/atoms/YearBudget/index.tsx b/src/components/atoms/YearBudget/index.tsx
new file mode 100644
index 0000000..6abcb36
--- /dev/null
+++ b/src/components/atoms/YearBudget/index.tsx
@@ -0,0 +1,14 @@
+import { FC } from 'react';
+
+type Props = {
+ budget: number;
+};
+
+export const YearBudget: FC = ({ budget }) => {
+ return (
+
+ );
+};
diff --git a/src/components/atoms/YearHeader/index.tsx b/src/components/atoms/YearHeader/index.tsx
new file mode 100644
index 0000000..b08df04
--- /dev/null
+++ b/src/components/atoms/YearHeader/index.tsx
@@ -0,0 +1,21 @@
+import { FC } from 'react';
+
+type Props = {
+ year: number;
+ onCopyPeople?: () => void;
+ isCopying?: boolean;
+};
+
+export const YearHeader: FC = ({ year, onCopyPeople, isCopying = false }) => {
+ return (
+
+
Rok {year}
+
+
+ );
+};
diff --git a/src/components/atoms/YearList/index.tsx b/src/components/atoms/YearList/index.tsx
new file mode 100644
index 0000000..9e75399
--- /dev/null
+++ b/src/components/atoms/YearList/index.tsx
@@ -0,0 +1,21 @@
+import { DB } from '@/lib/db';
+import { Button } from '../Button';
+
+export const YearList = async () => {
+ const years = await DB.getYears();
+
+ return (
+
+
+
+ {years.map((year) => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/atoms/YearNotes/index.tsx b/src/components/atoms/YearNotes/index.tsx
new file mode 100644
index 0000000..d5f5691
--- /dev/null
+++ b/src/components/atoms/YearNotes/index.tsx
@@ -0,0 +1,14 @@
+import { FC } from 'react';
+
+type Props = {
+ notes: string;
+};
+
+export const YearNotes: FC = ({ notes }) => {
+ return (
+
+ );
+};
diff --git a/src/components/molecules/ActionCard/index.tsx b/src/components/molecules/ActionCard/index.tsx
new file mode 100644
index 0000000..0ecc8ec
--- /dev/null
+++ b/src/components/molecules/ActionCard/index.tsx
@@ -0,0 +1,22 @@
+import { Heading } from '@/components/atoms/Heading';
+import { FC } from 'react';
+
+type Props = {
+ title: string;
+ description?: string;
+ backgroundColor?: string;
+ onClick: () => void;
+};
+
+export const ActionCard: FC = ({ title, description, backgroundColor = 'bg-white', onClick }) => {
+ return (
+
+
+ {title}
+
+ {description &&
{description}
}
+
+ );
+};
diff --git a/src/components/molecules/GiftCard/index.tsx b/src/components/molecules/GiftCard/index.tsx
new file mode 100644
index 0000000..6bf393d
--- /dev/null
+++ b/src/components/molecules/GiftCard/index.tsx
@@ -0,0 +1,91 @@
+'use client';
+import { FC, useState } from 'react';
+import { formatCurrency } from '@/helpers/formatCurrency';
+import { formatStatus } from '@/helpers/formatStatus';
+import { GiftModal } from '../GiftModal';
+import { useGlobalStore } from '@/app/store/global';
+import { DB } from '@/lib/db';
+import { useRouter } from 'next/navigation';
+import { Button } from '@/components/atoms/Button';
+
+type Props = {
+ hideDetails?: boolean;
+ editable?: boolean;
+ personId?: string;
+};
+
+export const GiftCard: FC = ({ hideDetails = false, editable = false, personId, ...gift }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const year = useGlobalStore((s) => s.year);
+ const router = useRouter();
+
+ const { title, description, cost, status, created, expand } = gift;
+
+ const bgByStatus = {
+ planned: 'bg-gray-100',
+ decided: 'bg-blue-100',
+ bought: 'bg-green-100',
+ wrapped: 'bg-purple-100',
+ };
+
+ const handleDelete = async () => {
+ await DB.deleteGift(gift.id);
+ router.refresh();
+ };
+
+ return (
+ <>
+ setIsOpen(true) : undefined}>
+
+
+
+
{title}
+
+ {description &&
{description}
}
+
+
Data dodania: {new Date(created).toLocaleDateString()}
+
•
+
Koszt: {formatCurrency(cost)}
+ {!hideDetails && (
+ <>
+
•
+
Dla: {expand?.person.name}
+ >
+ )}
+
•
+
Status: {formatStatus(status)}
+ {editable && (
+
+
+
+ )}
+
+
+
+
+
+ setIsOpen(false)}
+ yearId={year.id}
+ personId={personId}
+ gift={gift}
+ onSave={async (data) => {
+ await DB.updateGift(gift.id, data);
+ router.refresh();
+ }}
+ />
+ >
+ );
+};
diff --git a/src/components/molecules/GiftModal/index.tsx b/src/components/molecules/GiftModal/index.tsx
new file mode 100644
index 0000000..48c64fc
--- /dev/null
+++ b/src/components/molecules/GiftModal/index.tsx
@@ -0,0 +1,184 @@
+'use client';
+import { FC, useState, useEffect } from 'react';
+import { Modal } from '@/components/atoms/Modal';
+import { Button } from '@/components/atoms/Button';
+import { formatStatus } from '@/helpers/formatStatus';
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ gift?: Gift;
+ yearId: string;
+ personId?: string;
+ onSave: (data: {
+ title: string;
+ description: string;
+ link: string;
+ cost: number;
+ imageUrl: string;
+ status: 'planned' | 'decided' | 'bought' | 'wrapped';
+ year: string;
+ person: string;
+ }) => Promise;
+};
+
+export const GiftModal: FC = ({ isOpen, onClose, gift, yearId, personId, onSave }) => {
+ const [title, setTitle] = useState(gift?.title || '');
+ const [description, setDescription] = useState(gift?.description || '');
+ const [link, setLink] = useState(gift?.link || '');
+ const [cost, setCost] = useState(gift?.cost || 0);
+ const [imageUrl, setImageUrl] = useState(gift?.imageUrl || '');
+ const [status, setStatus] = useState(gift?.status || 'planned');
+ const [selectedPersonId, setSelectedPersonId] = useState(personId || '');
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (gift) {
+ setTitle(gift.title);
+ setDescription(gift.description || '');
+ setLink(gift.link || '');
+ setCost(gift.cost);
+ setImageUrl(gift.imageUrl || '');
+ setStatus(gift.status);
+ setSelectedPersonId(gift.person);
+ } else {
+ setTitle('');
+ setDescription('');
+ setLink('');
+ setCost(0);
+ setImageUrl('');
+ setStatus('planned');
+ setSelectedPersonId(personId || '');
+ }
+ }, [gift, personId, isOpen]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ try {
+ await onSave({
+ title,
+ description,
+ link,
+ cost,
+ imageUrl,
+ status,
+ year: yearId,
+ person: selectedPersonId,
+ });
+ onClose();
+ } catch (error) {
+ console.error('Error saving gift:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ }>
+
+
+ );
+};
diff --git a/src/components/molecules/PersonCard/client.tsx b/src/components/molecules/PersonCard/client.tsx
new file mode 100644
index 0000000..b406138
--- /dev/null
+++ b/src/components/molecules/PersonCard/client.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { Button } from '@/components/atoms/Button';
+import { FC, useState } from 'react';
+import { PersonModal } from '../PersonModal';
+import { DB } from '@/lib/db';
+import { useGlobalStore } from '@/app/store/global';
+import { useRouter } from 'next/navigation';
+import { GiftModal } from '../GiftModal';
+
+type Props = {
+ person: Person;
+};
+
+export const PersonCardEdit: FC = ({ person }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const year = useGlobalStore((s) => s.year);
+ const router = useRouter();
+
+ const handleDelete = async () => {
+ await DB.deletePerson(person.id);
+ router.refresh();
+ };
+
+ return (
+ <>
+
+
+
+
+ setIsOpen(false)}
+ year={year.id}
+ person={person}
+ onSave={async (data) => {
+ await DB.updatePerson(person.id, data);
+ router.refresh();
+ }}
+ />
+ >
+ );
+};
+
+export const GiftCardAdd: FC = ({ person }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const year = useGlobalStore((s) => s.year);
+ const router = useRouter();
+
+ return (
+ <>
+
+ setIsOpen(false)}
+ yearId={year.id}
+ personId={person.id}
+ onSave={async (data) => {
+ await DB.createGift(data);
+ router.refresh();
+ }}
+ />
+ >
+ );
+};
diff --git a/src/components/molecules/PersonCard/index.tsx b/src/components/molecules/PersonCard/index.tsx
new file mode 100644
index 0000000..43e1c92
--- /dev/null
+++ b/src/components/molecules/PersonCard/index.tsx
@@ -0,0 +1,36 @@
+import { Heading } from '@/components/atoms/Heading';
+import { formatCurrency } from '@/helpers/formatCurrency';
+import { FC } from 'react';
+import { GiftCard } from '../GiftCard';
+import { GiftCardAdd, PersonCardEdit } from './client';
+
+export const PersonCard: FC = (person) => {
+ const { name, notes, expand } = person;
+ const { gifts } = expand;
+
+ return (
+
+
+ {notes &&
{notes}
}
+
+ Ilość prezentów: {gifts?.length || 0}
+ •
+ Koszt: {formatCurrency(gifts?.reduce((acc, gift) => acc + gift.cost, 0) || 0)}
+
+
+
+ Prezenty
+
+
+
+ {gifts?.map((gift) => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/molecules/PersonModal/index.tsx b/src/components/molecules/PersonModal/index.tsx
new file mode 100644
index 0000000..2af88b1
--- /dev/null
+++ b/src/components/molecules/PersonModal/index.tsx
@@ -0,0 +1,88 @@
+'use client';
+import { FC, useState, useEffect } from 'react';
+import { Modal } from '@/components/atoms/Modal';
+import { Button } from '@/components/atoms/Button';
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ year: string;
+ person?: Person;
+ onSave: (data: { name: string; notes: string; year: string }) => Promise;
+};
+
+export const PersonModal: FC = ({ isOpen, onClose, year, person, onSave }) => {
+ const [name, setName] = useState('');
+ const [notes, setNotes] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (person) {
+ setName(person.name);
+ setNotes(person.notes || '');
+ } else {
+ setName('');
+ setNotes('');
+ }
+ }, [person, isOpen]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+ try {
+ await onSave({ name, notes, year });
+ onClose();
+ } catch (error) {
+ console.error('Error saving person:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ }>
+
+
+ );
+};
diff --git a/src/components/organisms/YearControls/index.tsx b/src/components/organisms/YearControls/index.tsx
new file mode 100644
index 0000000..e3e8c5b
--- /dev/null
+++ b/src/components/organisms/YearControls/index.tsx
@@ -0,0 +1,31 @@
+'use client';
+import { FC, useState } from 'react';
+import { ActionCard } from '../../molecules/ActionCard';
+import { PersonModal } from '../../molecules/PersonModal';
+import { DB } from '@/lib/db';
+import { useRouter } from 'next/navigation';
+
+type Props = {
+ year: string;
+};
+
+export const YearControls: FC = ({ year }) => {
+ const [modalOpen, setModalOpen] = useState<'addPerson' | null>(null);
+ const router = useRouter();
+ return (
+ <>
+
+
setModalOpen('addPerson')} backgroundColor='bg-green-50' />
+
+ setModalOpen(null)}
+ onSave={async (data) => {
+ await DB.createPerson(data);
+ router.refresh();
+ }}
+ />
+ >
+ );
+};
diff --git a/src/components/organisms/YearOverview/index.tsx b/src/components/organisms/YearOverview/index.tsx
new file mode 100644
index 0000000..61d61dd
--- /dev/null
+++ b/src/components/organisms/YearOverview/index.tsx
@@ -0,0 +1,51 @@
+import { StatsCard } from '@/components/atoms/StatsCard';
+import { DB } from '@/lib/db';
+import { formatCurrency } from '@/helpers/formatCurrency';
+import { FC } from 'react';
+import { Heading } from '@/components/atoms/Heading';
+
+type Props = {
+ year?: number;
+};
+
+export const YearOverview: FC = async ({ year }) => {
+ const currentYear = new Date().getFullYear();
+ const data = await DB.getYear(year ?? currentYear);
+
+ if (!data) {
+ return null;
+ }
+
+ const gifts = await Promise.all(
+ data.gifts.map(async (gift) => {
+ return await DB.getGift(gift);
+ }),
+ );
+
+ const totalCost = gifts.reduce((acc, gift) => acc + gift.cost, 0);
+ const averageCost = totalCost / gifts.length;
+
+ return (
+
+
Podsumowanie roku {data.year}
+
+
+
+ gift.status === 'decided').length}
+ description='Prezenty do kupienia'
+ />
+ gift.status === 'bought').length}
+ description='Prezenty które zostały już kupione'
+ />
+
+
+ );
+};
diff --git a/src/helpers/formatCurrency/index.ts b/src/helpers/formatCurrency/index.ts
new file mode 100644
index 0000000..8859d77
--- /dev/null
+++ b/src/helpers/formatCurrency/index.ts
@@ -0,0 +1,7 @@
+export const formatCurrency = (amount: number) => {
+ return new Intl.NumberFormat('pl-PL', {
+ style: 'currency',
+ currency: 'PLN',
+ useGrouping: true,
+ }).format(amount);
+};
diff --git a/src/helpers/formatStatus/index.ts b/src/helpers/formatStatus/index.ts
new file mode 100644
index 0000000..a683606
--- /dev/null
+++ b/src/helpers/formatStatus/index.ts
@@ -0,0 +1,12 @@
+export const formatStatus = (status: Gift['status']) => {
+ switch (status) {
+ case 'planned':
+ return 'Planowane';
+ case 'decided':
+ return 'Do kupienia';
+ case 'bought':
+ return 'Kupione';
+ case 'wrapped':
+ return 'Gotowe';
+ }
+};
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
new file mode 100644
index 0000000..0975254
--- /dev/null
+++ b/src/lib/db/index.ts
@@ -0,0 +1,106 @@
+import PocketBase from 'pocketbase';
+
+const pb = new PocketBase('https://db.maciaszek.ovh');
+
+export const DB = {
+ getYear: async (year: number): Promise => {
+ return await pb.collection('gifts_year').getFirstListItem(`year = ${year}`, {
+ expand: 'gifts.person',
+ fields:
+ '*,expand.gifts.*,expand.gifts.expand.person.name,expand.gifts.expand.person.id,expand.gifts.expand.person.notes',
+ });
+ },
+ getYears: async (): Promise => {
+ return await pb.collection('gifts_year').getFullList();
+ },
+
+ getPerson: async (name: string): Promise => {
+ return await pb.collection('gifts_person').getFirstListItem(`name = "${name}"`);
+ },
+ createPerson: async (data: { name: string; notes: string; year: string }): Promise => {
+ return await pb.collection('gifts_person').create({ ...data, years: data.year });
+ },
+ updatePerson: async (id: string, data: { name: string; notes: string }): Promise => {
+ return await pb.collection('gifts_person').update(id, data);
+ },
+ deletePerson: async (id: string): Promise => {
+ await pb.collection('gifts_person').delete(id);
+ },
+ getPersons: async (yearId: string): Promise => {
+ return await pb.collection('gifts_person').getFullList({
+ filter: `years ~ "${yearId}"`,
+ expand: 'gifts,years',
+ fields: '*,expand.gifts.*,expand.years.id,expand.years.year',
+ });
+ },
+
+ getGift: async (id: string): Promise => {
+ return await pb.collection('gifts_items').getOne(id, {
+ expand: 'year,person',
+ fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
+ });
+ },
+ createGift: async (data: {
+ title: string;
+ description: string;
+ link: string;
+ cost: number;
+ imageUrl: string;
+ status: 'planned' | 'decided' | 'bought' | 'wrapped';
+ year: string;
+ person: string;
+ }): Promise => {
+ const gift = await pb.collection('gifts_items').create(data);
+ console.log(gift);
+
+ pb.collection('gifts_person').update(data.person, {
+ 'gifts+': gift.id,
+ });
+
+ pb.collection('gifts_year').update(data.year, {
+ 'gifts+': gift.id,
+ });
+
+ return gift as unknown as Gift;
+ },
+ updateGift: async (
+ id: string,
+ data: {
+ title: string;
+ description: string;
+ link: string;
+ cost: number;
+ imageUrl: string;
+ status: 'planned' | 'decided' | 'bought' | 'wrapped';
+ year: string;
+ person: string;
+ },
+ ): Promise => {
+ return await pb.collection('gifts_items').update(id, data);
+ },
+ deleteGift: async (id: string): Promise => {
+ const gift = await pb.collection('gifts_items').getOne(id);
+ await pb.collection('gifts_items').delete(id);
+ await pb.collection('gifts_person').update(gift.person, {
+ 'gifts-': id,
+ });
+ await pb.collection('gifts_year').update(gift.year, {
+ 'gifts-': id,
+ });
+ },
+ getGifts: async (yearId: string): Promise => {
+ return await pb.collection('gifts_items').getFullList({
+ filter: `year = "${yearId}"`,
+ expand: 'year,person',
+ fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
+ });
+ },
+ getRecentGifts: async (): Promise => {
+ return await pb.collection('gifts_items').getFullList({
+ sort: 'created',
+ limit: 3,
+ expand: 'year,person',
+ fields: '*,expand.year.year,expand.person.name,expand.year.id,expand.person.id',
+ });
+ },
+};
diff --git a/src/lib/db/types.d.ts b/src/lib/db/types.d.ts
new file mode 100644
index 0000000..3a9bbb5
--- /dev/null
+++ b/src/lib/db/types.d.ts
@@ -0,0 +1,35 @@
+namespace DB {
+ type Year = {
+ id: string;
+ year: number;
+ notes: string;
+ budgetLimit: number;
+ created: Date;
+ updated: Date;
+ gifts: string[];
+ };
+
+ type Person = {
+ id: string;
+ name: string;
+ notes: string;
+ created: Date;
+ updated: Date;
+ gifts: string[];
+ };
+
+ type Gift = {
+ id: string;
+ title: string;
+ description: string;
+ link: string;
+ cost: number;
+ imageUrl: string;
+ status: 'planned' | 'decided' | 'bought' | 'wrapped';
+ created: Date;
+ updated: Date;
+
+ year: string;
+ person: string;
+ };
+}
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
new file mode 100644
index 0000000..9f34226
--- /dev/null
+++ b/src/types/global.d.ts
@@ -0,0 +1,61 @@
+type Year = {
+ id: string;
+ year: number;
+ notes: string;
+ budgetLimit: number;
+ created: Date;
+ updated: Date;
+ gifts: string[];
+ expand: {
+ gifts: Gift[] & {
+ expand: {
+ person: {
+ id: string;
+ name: string;
+ notes: string;
+ };
+ };
+ };
+ };
+};
+
+type Person = {
+ id: string;
+ name: string;
+ notes: string;
+ created: Date;
+ updated: Date;
+ gifts: string[];
+ expand: {
+ gifts?: Gift[];
+ year: {
+ id: string;
+ year: number;
+ };
+ };
+};
+
+type Gift = {
+ id: string;
+ title: string;
+ description: string;
+ link: string;
+ cost: number;
+ imageUrl: string;
+ status: 'planned' | 'decided' | 'bought' | 'wrapped';
+ created: Date;
+ updated: Date;
+
+ year: string;
+ person: string;
+ expand: {
+ year: {
+ id: string;
+ year: number;
+ };
+ person: {
+ id: string;
+ name: string;
+ };
+ };
+};