Add initial project setup with PocketBase integration, global state management, and UI components for gift tracking
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user