Add initial project setup with PocketBase integration, global state management, and UI components for gift tracking
This commit is contained in:
14
.prettierrc.json
Normal file
14
.prettierrc.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -9,8 +9,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.0.1",
|
"next": "16.0.1",
|
||||||
|
"pocketbase": "^0.26.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0"
|
"react-dom": "19.2.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1023,7 +1025,7 @@
|
|||||||
"version": "19.2.2",
|
"version": "19.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
@@ -1079,7 +1081,7 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
@@ -1498,6 +1500,12 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -1691,6 +1699,35 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -8,17 +8,19 @@
|
|||||||
"start": "next start"
|
"start": "next start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"next": "16.0.1",
|
||||||
|
"pocketbase": "^0.26.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"next": "16.0.1"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"@tailwindcss/postcss": "^4",
|
||||||
"typescript": "^5",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"tailwindcss": "^4"
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
body {
|
||||||
background: var(--background);
|
@apply bg-gray-50 text-gray-800 font-sans antialiased;
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import './globals.css';
|
||||||
import "./globals.css";
|
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: 'Gift Tracker',
|
||||||
description: "Generated by create next app",
|
description: 'Track and manage your gifts',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +12,13 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang='en'>
|
||||||
<body
|
<body className='font-sans'>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<div className='min-h-screen bg-gray-50'>
|
||||||
>
|
<main className='pt-16 pb-8'>
|
||||||
{children}
|
<div className='max-w-7xl mx-auto px-4 md:px-6 py-6 md:py-8'>{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<>
|
||||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
<YearOverview />
|
||||||
<Image
|
<YearList />
|
||||||
className="dark:invert"
|
<Heading>Ostatnie prezenty</Heading>
|
||||||
src="/next.svg"
|
{recentGifts.map((gift) => (
|
||||||
alt="Next.js logo"
|
<GiftCard key={gift.id} {...gift} />
|
||||||
width={100}
|
))}
|
||||||
height={20}
|
</>
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/app/store/global.tsx
Normal file
32
src/app/store/global.tsx
Normal file
@@ -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<Props>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGlobalStore = create<State>((set) => ({
|
||||||
|
year: {} as Year,
|
||||||
|
persons: [] as Person[],
|
||||||
|
gifts: [] as Gift[],
|
||||||
|
|
||||||
|
hydrate: (data: Partial<Props>) => set(data),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const GlobalStore = ({ children, year, persons, gifts }: Partial<Props> & { children: React.ReactNode }) => {
|
||||||
|
const hydrate = useGlobalStore((s) => s.hydrate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hydrate({ year, persons, gifts });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
24
src/app/year/[year]/page.tsx
Normal file
24
src/app/year/[year]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<GlobalStore year={data} persons={persons} gifts={[]}>
|
||||||
|
<YearOverview year={Number(year)} />
|
||||||
|
<YearList />
|
||||||
|
<YearControls year={data.id} />
|
||||||
|
{persons.map((person) => (
|
||||||
|
<PersonCard key={person.name} {...person} />
|
||||||
|
))}
|
||||||
|
</GlobalStore>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/atoms/Badge/index.tsx
Normal file
11
src/components/atoms/Badge/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Badge: FC<Props> = ({ children, onClick, disabled }) => {
|
||||||
|
return <span className='px-2 py-1 text-xs font-medium text-blue-600 bg-blue-50 rounded-lg'>{children}</span>;
|
||||||
|
};
|
||||||
34
src/components/atoms/Button/index.tsx
Normal file
34
src/components/atoms/Button/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
variant?: keyof typeof variants;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
export const Button: FC<Props> = ({ children, onClick, disabled, variant = 'primary', href }) => {
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className={variants[variant]}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={variants[variant]} onClick={onClick} disabled={disabled}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
22
src/components/atoms/Heading/index.tsx
Normal file
22
src/components/atoms/Heading/index.tsx
Normal file
@@ -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<Props> = ({ 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 <h2 className={`${sizeClass[size]} font-semibold text-gray-800 ${spacingClass[spacing]}`}>{children}</h2>;
|
||||||
|
};
|
||||||
46
src/components/atoms/Modal/index.tsx
Normal file
46
src/components/atoms/Modal/index.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4' onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className='bg-white rounded-2xl shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto'
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className='sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between rounded-t-2xl'>
|
||||||
|
<h2 className='text-2xl font-semibold text-gray-800'>{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className='text-gray-400 hover:text-gray-600 transition-colors text-2xl leading-none'>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='p-6'>{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<div className='sticky bottom-0 bg-white border-t border-gray-200 px-6 py-4 rounded-b-2xl'>{footer}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
src/components/atoms/StatsCard/index.tsx
Normal file
17
src/components/atoms/StatsCard/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
title: string;
|
||||||
|
value: number | string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatsCard: FC<Props> = ({ title, value, description }) => {
|
||||||
|
return (
|
||||||
|
<div className='bg-white rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out'>
|
||||||
|
<div className='text-sm text-gray-600 mb-1'>{title}</div>
|
||||||
|
<div className='text-3xl font-bold text-gray-800'>{value}</div>
|
||||||
|
{description && <div className='text-xs text-gray-500 mt-2'>{description}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
src/components/atoms/YearBudget/index.tsx
Normal file
14
src/components/atoms/YearBudget/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
budget: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const YearBudget: FC<Props> = ({ budget }) => {
|
||||||
|
return (
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h2 className='text-2xl font-semibold text-gray-800 mb-4'>Budżet</h2>
|
||||||
|
<p className='text-gray-600'>{budget}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/components/atoms/YearHeader/index.tsx
Normal file
21
src/components/atoms/YearHeader/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
year: number;
|
||||||
|
onCopyPeople?: () => void;
|
||||||
|
isCopying?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const YearHeader: FC<Props> = ({ year, onCopyPeople, isCopying = false }) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col sm:flex-row items-start sm:items-center justify-between mb-6 gap-4'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-800'>Rok {year}</h1>
|
||||||
|
<button
|
||||||
|
className='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'
|
||||||
|
onClick={onCopyPeople}
|
||||||
|
disabled={isCopying}>
|
||||||
|
{isCopying ? 'Kopiowanie...' : 'Skopiuj osoby z poprzedniego roku'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
src/components/atoms/YearList/index.tsx
Normal file
21
src/components/atoms/YearList/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { DB } from '@/lib/db';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
|
||||||
|
export const YearList = async () => {
|
||||||
|
const years = await DB.getYears();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mb-6 flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Button variant='secondary' href='/'>
|
||||||
|
Wszystkie lata
|
||||||
|
</Button>
|
||||||
|
{years.map((year) => (
|
||||||
|
<Button key={year.id} href={`/year/${year.year}`} variant='secondary'>
|
||||||
|
{year.year}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
14
src/components/atoms/YearNotes/index.tsx
Normal file
14
src/components/atoms/YearNotes/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const YearNotes: FC<Props> = ({ notes }) => {
|
||||||
|
return (
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h2 className='text-2xl font-semibold text-gray-800 mb-4'>Notes</h2>
|
||||||
|
<p className='text-gray-600'>{notes}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
22
src/components/molecules/ActionCard/index.tsx
Normal file
22
src/components/molecules/ActionCard/index.tsx
Normal file
@@ -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<Props> = ({ title, description, backgroundColor = 'bg-white', onClick }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${backgroundColor} inline-block rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out cursor-pointer`}
|
||||||
|
onClick={onClick}>
|
||||||
|
<Heading size='small' spacing='none'>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
{description && <p className='text-sm text-gray-600'>{description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
91
src/components/molecules/GiftCard/index.tsx
Normal file
91
src/components/molecules/GiftCard/index.tsx
Normal file
@@ -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<Gift & Props> = ({ 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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl shadow-sm p-4 my-6 md:p-6 transition-all duration-200 ease-in-out ${
|
||||||
|
bgByStatus[status]
|
||||||
|
} ${editable ? 'cursor-pointer hover:shadow-md ' : ''}`}
|
||||||
|
onClick={editable ? () => setIsOpen(true) : undefined}>
|
||||||
|
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<div className='flex items-center justify-between gap-3 mb-2 pb-2 border-b border-gray-200'>
|
||||||
|
<h3 className='text-lg font-semibold text-gray-800'>{title}</h3>
|
||||||
|
</div>
|
||||||
|
{description && <p className='text-base text-gray-600 mb-3'>{description}</p>}
|
||||||
|
<div className='flex flex-wrap items-center gap-4 text-sm text-gray-500'>
|
||||||
|
<span>Data dodania: {new Date(created).toLocaleDateString()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Koszt: {formatCurrency(cost)}</span>
|
||||||
|
{!hideDetails && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Dla: {expand?.person.name}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>•</span>
|
||||||
|
<span>Status: {formatStatus(status)}</span>
|
||||||
|
{editable && (
|
||||||
|
<div className='ml-auto'>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete();
|
||||||
|
}}>
|
||||||
|
Usuń
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GiftModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
yearId={year.id}
|
||||||
|
personId={personId}
|
||||||
|
gift={gift}
|
||||||
|
onSave={async (data) => {
|
||||||
|
await DB.updateGift(gift.id, data);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
184
src/components/molecules/GiftModal/index.tsx
Normal file
184
src/components/molecules/GiftModal/index.tsx
Normal file
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GiftModal: FC<Props> = ({ 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']>(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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={gift ? 'Edytuj prezent' : 'Dodaj nowy prezent'}
|
||||||
|
footer={
|
||||||
|
<div className='flex justify-end gap-3'>
|
||||||
|
<Button variant='secondary' onClick={onClose} disabled={isLoading}>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button variant='primary' onClick={handleSubmit} disabled={isLoading || !title.trim() || !selectedPersonId}>
|
||||||
|
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='title' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Tytuł *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='title'
|
||||||
|
type='text'
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='description' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Opis
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id='description'
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all resize-none'
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='cost' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Koszt (PLN) *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='cost'
|
||||||
|
type='number'
|
||||||
|
step='0.01'
|
||||||
|
min='0'
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(parseFloat(e.target.value) || 0)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='status' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Status *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id='status'
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => setStatus(e.target.value as typeof status)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
required
|
||||||
|
disabled={isLoading}>
|
||||||
|
<option value='planned'>{formatStatus('planned')} </option>
|
||||||
|
<option value='decided'>{formatStatus('decided')}</option>
|
||||||
|
<option value='bought'>{formatStatus('bought')}</option>
|
||||||
|
<option value='wrapped'>{formatStatus('wrapped')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='link' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='link'
|
||||||
|
type='url'
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='imageUrl' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
URL obrazu
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='imageUrl'
|
||||||
|
type='url'
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
71
src/components/molecules/PersonCard/client.tsx
Normal file
71
src/components/molecules/PersonCard/client.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4'>
|
||||||
|
<Button variant='primary' onClick={() => setIsOpen(true)}>
|
||||||
|
Edytuj
|
||||||
|
</Button>
|
||||||
|
<Button variant='secondary' onClick={handleDelete}>
|
||||||
|
Usuń
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<PersonModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
year={year.id}
|
||||||
|
person={person}
|
||||||
|
onSave={async (data) => {
|
||||||
|
await DB.updatePerson(person.id, data);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GiftCardAdd: FC<Props> = ({ person }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const year = useGlobalStore((s) => s.year);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant='secondary' onClick={() => setIsOpen(true)}>
|
||||||
|
+
|
||||||
|
</Button>
|
||||||
|
<GiftModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
yearId={year.id}
|
||||||
|
personId={person.id}
|
||||||
|
onSave={async (data) => {
|
||||||
|
await DB.createGift(data);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
36
src/components/molecules/PersonCard/index.tsx
Normal file
36
src/components/molecules/PersonCard/index.tsx
Normal file
@@ -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> = (person) => {
|
||||||
|
const { name, notes, expand } = person;
|
||||||
|
const { gifts } = expand;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='bg-white rounded-2xl shadow-sm hover:shadow-md p-4 md:p-6 transition-all duration-200 ease-in-out mt-6'>
|
||||||
|
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pb-2 mb-6 border-b border-gray-200'>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<Heading size='large'>{name}</Heading>
|
||||||
|
</div>
|
||||||
|
<PersonCardEdit person={person} />
|
||||||
|
</div>
|
||||||
|
{notes && <p className='text-gray-600'>{notes}</p>}
|
||||||
|
<div className='flex flex-wrap items-center gap-4 text-sm text-gray-500'>
|
||||||
|
<span>Ilość prezentów: {gifts?.length || 0}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Koszt: {formatCurrency(gifts?.reduce((acc, gift) => acc + gift.cost, 0) || 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className='mt-8 mb-4 flex items-center gap-4'>
|
||||||
|
<Heading size='medium' spacing='none'>
|
||||||
|
Prezenty
|
||||||
|
</Heading>
|
||||||
|
<GiftCardAdd person={person} />
|
||||||
|
</div>
|
||||||
|
{gifts?.map((gift) => (
|
||||||
|
<GiftCard key={gift.id} {...gift} hideDetails editable personId={person.id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
88
src/components/molecules/PersonModal/index.tsx
Normal file
88
src/components/molecules/PersonModal/index.tsx
Normal file
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PersonModal: FC<Props> = ({ 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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={person ? 'Edytuj osobę' : 'Dodaj nową osobę'}
|
||||||
|
footer={
|
||||||
|
<div className='flex justify-end gap-3'>
|
||||||
|
<Button variant='secondary' onClick={onClose} disabled={isLoading}>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button variant='primary' onClick={handleSubmit} disabled={isLoading || !name.trim()}>
|
||||||
|
{isLoading ? 'Zapisywanie...' : 'Zapisz'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='name' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Imię i nazwisko *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id='name'
|
||||||
|
type='text'
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all'
|
||||||
|
required
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor='notes' className='block text-sm font-medium text-gray-700 mb-2'>
|
||||||
|
Notatki
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id='notes'
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className='w-full px-4 py-2 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all resize-none'
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
31
src/components/organisms/YearControls/index.tsx
Normal file
31
src/components/organisms/YearControls/index.tsx
Normal file
@@ -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<Props> = ({ year }) => {
|
||||||
|
const [modalOpen, setModalOpen] = useState<'addPerson' | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8'>
|
||||||
|
<ActionCard title='Dodaj nową osobę' onClick={() => setModalOpen('addPerson')} backgroundColor='bg-green-50' />
|
||||||
|
</div>
|
||||||
|
<PersonModal
|
||||||
|
isOpen={modalOpen === 'addPerson'}
|
||||||
|
year={year}
|
||||||
|
onClose={() => setModalOpen(null)}
|
||||||
|
onSave={async (data) => {
|
||||||
|
await DB.createPerson(data);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
51
src/components/organisms/YearOverview/index.tsx
Normal file
51
src/components/organisms/YearOverview/index.tsx
Normal file
@@ -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<Props> = 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 (
|
||||||
|
<div className='mb-6 md:mb-8'>
|
||||||
|
<Heading size='large'>Podsumowanie roku {data.year}</Heading>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||||
|
<StatsCard title='Planowane prezenty' value={gifts.length} />
|
||||||
|
<StatsCard
|
||||||
|
title='Łączny koszt'
|
||||||
|
value={formatCurrency(totalCost)}
|
||||||
|
description={`Średnio: ${formatCurrency(averageCost)}`}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title='Status: Do kupienia'
|
||||||
|
value={gifts.filter((gift) => gift.status === 'decided').length}
|
||||||
|
description='Prezenty do kupienia'
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title='Status: Kupione'
|
||||||
|
value={gifts.filter((gift) => gift.status === 'bought').length}
|
||||||
|
description='Prezenty które zostały już kupione'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
src/helpers/formatCurrency/index.ts
Normal file
7
src/helpers/formatCurrency/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('pl-PL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'PLN',
|
||||||
|
useGrouping: true,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
12
src/helpers/formatStatus/index.ts
Normal file
12
src/helpers/formatStatus/index.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
|
};
|
||||||
106
src/lib/db/index.ts
Normal file
106
src/lib/db/index.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const pb = new PocketBase('https://db.maciaszek.ovh');
|
||||||
|
|
||||||
|
export const DB = {
|
||||||
|
getYear: async (year: number): Promise<Year> => {
|
||||||
|
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<DB.Year[]> => {
|
||||||
|
return await pb.collection('gifts_year').getFullList();
|
||||||
|
},
|
||||||
|
|
||||||
|
getPerson: async (name: string): Promise<Person> => {
|
||||||
|
return await pb.collection('gifts_person').getFirstListItem(`name = "${name}"`);
|
||||||
|
},
|
||||||
|
createPerson: async (data: { name: string; notes: string; year: string }): Promise<Person> => {
|
||||||
|
return await pb.collection('gifts_person').create({ ...data, years: data.year });
|
||||||
|
},
|
||||||
|
updatePerson: async (id: string, data: { name: string; notes: string }): Promise<Person> => {
|
||||||
|
return await pb.collection('gifts_person').update(id, data);
|
||||||
|
},
|
||||||
|
deletePerson: async (id: string): Promise<void> => {
|
||||||
|
await pb.collection('gifts_person').delete(id);
|
||||||
|
},
|
||||||
|
getPersons: async (yearId: string): Promise<Person[]> => {
|
||||||
|
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<Gift> => {
|
||||||
|
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<Gift> => {
|
||||||
|
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<Gift> => {
|
||||||
|
return await pb.collection('gifts_items').update(id, data);
|
||||||
|
},
|
||||||
|
deleteGift: async (id: string): Promise<void> => {
|
||||||
|
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<Gift[]> => {
|
||||||
|
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<Gift[]> => {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
35
src/lib/db/types.d.ts
vendored
Normal file
35
src/lib/db/types.d.ts
vendored
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
61
src/types/global.d.ts
vendored
Normal file
61
src/types/global.d.ts
vendored
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user