Basic transaction system
This commit is contained in:
@ -13,10 +13,11 @@
|
||||
"dependencies": {
|
||||
"@mdi/js": "^7.3.67",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@redwoodjs/auth-dbauth-web": "6.3.2",
|
||||
"@redwoodjs/forms": "6.3.2",
|
||||
"@redwoodjs/router": "6.3.2",
|
||||
"@redwoodjs/web": "6.3.2",
|
||||
"@redwoodjs/auth-dbauth-web": "6.3.3",
|
||||
"@redwoodjs/forms": "6.3.3",
|
||||
"@redwoodjs/router": "6.3.3",
|
||||
"@redwoodjs/web": "6.3.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"filestack-react": "^4.0.1",
|
||||
"humanize-string": "2.1.0",
|
||||
"prop-types": "15.8.1",
|
||||
@ -25,7 +26,7 @@
|
||||
"theme-change": "^2.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/vite": "6.3.2",
|
||||
"@redwoodjs/vite": "6.3.3",
|
||||
"@types/filestack-react": "^4.0.3",
|
||||
"@types/node": "^20.8.9",
|
||||
"@types/react": "18.2.14",
|
||||
|
@ -20,12 +20,18 @@ const Routes = () => {
|
||||
<Route path="/admin/parts/{id:Int}" page={PartPartPage} name="part" />
|
||||
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
|
||||
</Set>
|
||||
<Set wrap={ScaffoldLayout} title="Transactions" titleTo="adminTransactions">
|
||||
<Route path="/admin/transactions" page={AdminTransactionsPage} name="adminTransactions" />
|
||||
</Set>
|
||||
</Private>
|
||||
|
||||
<Set wrap={NavbarLayout}>
|
||||
<Route path="/" page={HomePage} name="home" />
|
||||
<Route path="/part/{id:Int}" page={PartPage} name="partDetails" />
|
||||
<Route path="/basket" page={BasketPage} name="basket" />
|
||||
<Private unauthenticated="login">
|
||||
<Route path="/transactions" page={TransactionsPage} name="userTransactions" />
|
||||
</Private>
|
||||
</Set>
|
||||
|
||||
<Route notfound page={NotFoundPage} />
|
||||
|
25
web/src/components/AdminMenu/AdminMenu.stories.tsx
Normal file
25
web/src/components/AdminMenu/AdminMenu.stories.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
// Pass props to your component by passing an `args` object to your story
|
||||
//
|
||||
// ```tsx
|
||||
// export const Primary: Story = {
|
||||
// args: {
|
||||
// propName: propValue
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See https://storybook.js.org/docs/react/writing-stories/args.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import AdminMenu from './AdminMenu'
|
||||
|
||||
const meta: Meta<typeof AdminMenu> = {
|
||||
component: AdminMenu,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof AdminMenu>
|
||||
|
||||
export const Primary: Story = {}
|
14
web/src/components/AdminMenu/AdminMenu.test.tsx
Normal file
14
web/src/components/AdminMenu/AdminMenu.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import AdminMenu from './AdminMenu'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('AdminMenu', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<AdminMenu />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
53
web/src/components/AdminMenu/AdminMenu.tsx
Normal file
53
web/src/components/AdminMenu/AdminMenu.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { mdiWrench } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
interface Props {
|
||||
mobile: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AdminMenu = ({ mobile, className }: Props) => {
|
||||
const { isAuthenticated, hasRole } = useAuth()
|
||||
|
||||
return isAuthenticated && hasRole('admin') ? (
|
||||
<div className={className}>
|
||||
<details
|
||||
className={`dropdown ${
|
||||
mobile ? 'dropdown-start space-y-2' : 'dropdown-end space-y-4'
|
||||
}`}
|
||||
>
|
||||
<summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
|
||||
<Icon path={mdiWrench} className="h-8 w-8 text-base-content" />
|
||||
</summary>
|
||||
<div className="dropdown-content flex flex-col items-center space-y-3 rounded-xl bg-base-100 p-3 shadow-lg">
|
||||
<ul className="space-y-3 bg-base-100 text-base-content">
|
||||
<li>
|
||||
<Link
|
||||
to={routes.parts()}
|
||||
className="btn btn-ghost w-full font-inter hover:shadow-lg"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={routes.adminTransactions()}
|
||||
className="btn btn-ghost w-full hover:shadow-lg"
|
||||
>
|
||||
<p className="font-inter">Transactions</p>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminMenu
|
@ -0,0 +1,4 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
userTransactions: [{ id: 42 }, { id: 43 }, { id: 44 }],
|
||||
})
|
@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
|
||||
import { standard } from './UserTransactionsCell.mock'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Cells/UserTransactionsCell',
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const loading: StoryObj<typeof Loading> = {
|
||||
render: () => {
|
||||
return Loading ? <Loading /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const empty: StoryObj<typeof Empty> = {
|
||||
render: () => {
|
||||
return Empty ? <Empty /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const failure: StoryObj<typeof Failure> = {
|
||||
render: (args) => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} {...args} /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const success: StoryObj<typeof Success> = {
|
||||
render: (args) => {
|
||||
return Success ? <Success {...standard()} {...args} /> : <></>
|
||||
},
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './AdminTransactionsCell'
|
||||
import { standard } from './AdminTransactionsCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('UserTransactionsCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success userTransactions={standard().userTransactions} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
@ -0,0 +1,104 @@
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
import { mdiAlert } from '@mdi/js'
|
||||
import { Icon } from '@mdi/react'
|
||||
import type { TransactionsQuery } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import TransactionListItem from '../TransactionListItem/TransactionListItem'
|
||||
|
||||
export const beforeQuery = ({ filter }) => {
|
||||
filter = filter && ['both', 'in', 'out'].includes(filter) ? filter : 'both'
|
||||
|
||||
return { variables: { filter } }
|
||||
}
|
||||
|
||||
export const QUERY = gql`
|
||||
query TransactionsQuery($filter: FilterTransactionsByType!) {
|
||||
transactions(filter: $filter) {
|
||||
transactions {
|
||||
id
|
||||
date
|
||||
parts
|
||||
type
|
||||
user {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
filter
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<p className="loading loading-bars loading-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="flex">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">Error! {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
transactions,
|
||||
}: CellSuccessProps<TransactionsQuery>) => {
|
||||
if (transactions.transactions.length == 0) return Empty()
|
||||
return (
|
||||
<div className="flex flex-col space-y-6 font-inter">
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn m-1 normal-case">
|
||||
Filter:{' '}
|
||||
{transactions.filter == 'both'
|
||||
? 'None'
|
||||
: transactions.filter.replace(
|
||||
transactions.filter[0],
|
||||
transactions.filter[0].toUpperCase()
|
||||
)}
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content rounded-box z-10 mt-3 w-auto space-y-3 bg-base-100 p-3 shadow"
|
||||
>
|
||||
{['None', 'In', 'Out'].map((filter) => (
|
||||
<li key={filter}>
|
||||
<Link
|
||||
className="btn btn-ghost w-full normal-case"
|
||||
to={routes.adminTransactions({
|
||||
filter: filter === 'None' ? 'both' : filter.toLowerCase(),
|
||||
})}
|
||||
>
|
||||
{filter}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{transactions.transactions.map((item, i) => {
|
||||
return (
|
||||
<TransactionListItem
|
||||
transaction={item}
|
||||
key={i}
|
||||
returnButton={false}
|
||||
admin
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
6
web/src/components/BasketCell/BasketCell.mock.ts
Normal file
6
web/src/components/BasketCell/BasketCell.mock.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
basket: {
|
||||
id: 42,
|
||||
},
|
||||
})
|
34
web/src/components/BasketCell/BasketCell.stories.tsx
Normal file
34
web/src/components/BasketCell/BasketCell.stories.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './BasketCell'
|
||||
import { standard } from './BasketCell.mock'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Cells/BasketCell',
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const loading: StoryObj<typeof Loading> = {
|
||||
render: () => {
|
||||
return Loading ? <Loading /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const empty: StoryObj<typeof Empty> = {
|
||||
render: () => {
|
||||
return Empty ? <Empty /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const failure: StoryObj<typeof Failure> = {
|
||||
render: (args) => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} {...args} /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const success: StoryObj<typeof Success> = {
|
||||
render: (args) => {
|
||||
return Success ? <Success {...standard()} {...args} /> : <></>
|
||||
},
|
||||
}
|
41
web/src/components/BasketCell/BasketCell.test.tsx
Normal file
41
web/src/components/BasketCell/BasketCell.test.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './BasketCell'
|
||||
import { standard } from './BasketCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('BasketCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success basket={standard().basket} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
207
web/src/components/BasketCell/BasketCell.tsx
Normal file
207
web/src/components/BasketCell/BasketCell.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { mdiMinus, mdiPlus, mdiDelete } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { CreateTransactionInput } from 'types/graphql'
|
||||
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/dist/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import {
|
||||
getBasket,
|
||||
setBasket,
|
||||
removeFromBasket,
|
||||
clearBasket,
|
||||
} from 'src/lib/basket'
|
||||
|
||||
import ToastNotification from '../ToastNotification'
|
||||
|
||||
export const CREATE_TRANSACTION_MUTATION = gql`
|
||||
mutation CreateTransactionMutation($input: CreateTransactionInput!) {
|
||||
createTransaction(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const thumbnail = (url: string) => {
|
||||
if (url.includes('no_image.png')) return url
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=width:160')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
export const BasketCell = () => {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
const [basket, setBasketState] = useState(getBasket())
|
||||
|
||||
const [createTransaction, { loading }] = useMutation(
|
||||
CREATE_TRANSACTION_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message="Transaction complete"
|
||||
/>
|
||||
))
|
||||
|
||||
setBasketState(clearBasket())
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.custom((t) => (
|
||||
<ToastNotification toast={t} type="error" message={error.message} />
|
||||
))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const submitBasket = (input: CreateTransactionInput) => {
|
||||
createTransaction({ variables: { input } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{basket.length > 0 ? (
|
||||
basket.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex max-w-5xl items-center rounded-xl bg-base-200 shadow-xl"
|
||||
>
|
||||
<img
|
||||
alt={item.part.name}
|
||||
className="hidden h-20 w-20 rounded-l-xl object-cover sm:flex"
|
||||
src={thumbnail(item.part.imageUrl)}
|
||||
/>
|
||||
<div className="m-3 w-full items-center justify-between space-y-3 sm:flex sm:space-y-0">
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap font-inter text-lg font-bold">
|
||||
{item.part.name}
|
||||
</p>
|
||||
<div className="flex justify-between space-x-3">
|
||||
<div className="join">
|
||||
<button
|
||||
className={`btn join-item ${
|
||||
item.quantity <= 1 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newBasket = basket
|
||||
newBasket[i].quantity -= 1
|
||||
setBasketState(setBasket(newBasket))
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiMinus} className="h-6 w-6" />
|
||||
</button>
|
||||
<p className="btn join-item items-center font-inter text-lg">
|
||||
{item.quantity}
|
||||
</p>
|
||||
<button
|
||||
className={`btn join-item ${
|
||||
item.quantity >= item.part.availableStock
|
||||
? 'btn-disabled'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newBasket = basket
|
||||
newBasket[i].quantity += 1
|
||||
setBasketState(setBasket(newBasket))
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiPlus} className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost hover:shadow-lg"
|
||||
onClick={() => {
|
||||
const newBasket = removeFromBasket(i)
|
||||
|
||||
if (typeof newBasket == 'string')
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message={newBasket}
|
||||
/>
|
||||
))
|
||||
else {
|
||||
setBasketState(newBasket)
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message={`Removed ${item.part.name} from basket`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
path={mdiDelete}
|
||||
className="h-8 w-8 text-base-content"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="alert w-auto shadow-lg">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{basket.length > 0 ? (
|
||||
<div className="flex space-x-3 pt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setBasketState(clearBasket())
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message="Basket cleared"
|
||||
/>
|
||||
))
|
||||
}}
|
||||
className="btn font-inter"
|
||||
>
|
||||
Clear basket
|
||||
</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
if (!isAuthenticated)
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message="You must be logged in to do that"
|
||||
/>
|
||||
))
|
||||
else {
|
||||
submitBasket({
|
||||
date: new Date().toISOString(),
|
||||
type: 'out',
|
||||
userId: currentUser.id,
|
||||
parts: basket.map((item) => ({
|
||||
part: JSON.stringify(item.part),
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`btn btn-primary font-inter ${
|
||||
loading ? 'btn-disabled' : ''
|
||||
}`}
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -40,18 +40,25 @@ const Part = ({ part }: Props) => {
|
||||
}
|
||||
}
|
||||
|
||||
const preview = (url: string) => {
|
||||
if (url.includes('no_image.png')) return url
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=height:500')
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rw-segment">
|
||||
<div className="rw-segment font-inter">
|
||||
<header className="rw-segment-header">
|
||||
<h2 className="rw-heading rw-heading-secondary">
|
||||
Part {part.id} Detail
|
||||
Part {part.id} details
|
||||
</h2>
|
||||
</header>
|
||||
<table className="rw-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Id</th>
|
||||
<th>ID</th>
|
||||
<td>{part.id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@ -68,7 +75,13 @@ const Part = ({ part }: Props) => {
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<td>{part.imageUrl}</td>
|
||||
<td>
|
||||
<img
|
||||
alt=""
|
||||
src={preview(part.imageUrl)}
|
||||
style={{ display: 'block', margin: '2rem 0' }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created at</th>
|
||||
|
@ -28,11 +28,11 @@ const PartForm = (props: PartFormProps) => {
|
||||
const [imageUrl, setImageUrl] = useState(props?.part?.imageUrl)
|
||||
|
||||
const onSubmit = (data: FormPart) => {
|
||||
console.log(imageUrl)
|
||||
const dataWithImageUrl = Object.assign(data, {
|
||||
const dataUpdated = Object.assign(data, {
|
||||
imageUrl: imageUrl ?? '/no_image.png',
|
||||
})
|
||||
props.onSave(dataWithImageUrl, props?.part?.id)
|
||||
|
||||
props.onSave(dataUpdated, props?.part?.id)
|
||||
}
|
||||
|
||||
const onImageUpload = (response) => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { mdiAlert } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import type { FindParts } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import Parts from 'src/components/Part/Parts'
|
||||
@ -18,21 +19,27 @@ export const QUERY = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => <div>Loading...</div>
|
||||
export const Loading = () => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<p className="loading loading-bars loading-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Empty = () => {
|
||||
return (
|
||||
<div className="rw-text-center p-4">
|
||||
<span className="font-inter">No parts yet.</span>{' '}
|
||||
<Link to={routes.newPart()} className="rw-link">
|
||||
{'Create one?'}
|
||||
</Link>
|
||||
export const Empty = () => (
|
||||
<div className="flex justify-center">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="rw-cell-error">{error?.message}</div>
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">Error! {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Success = ({ parts }: CellSuccessProps<FindParts>) => {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { Part } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
@ -6,14 +8,7 @@ import { addToBasket } from 'src/lib/basket'
|
||||
import ToastNotification from '../ToastNotification'
|
||||
|
||||
interface Props {
|
||||
part: {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
availableStock: number
|
||||
imageUrl: string
|
||||
createdAt: string
|
||||
}
|
||||
part: Part
|
||||
}
|
||||
|
||||
const thumbnail = (url: string) => {
|
||||
@ -25,66 +20,66 @@ const thumbnail = (url: string) => {
|
||||
|
||||
const PartsGridUnit = ({ part }: Props) => {
|
||||
return (
|
||||
<div className="card-compact card w-72 space-y-3 bg-base-100 font-inter shadow-xl sm:w-96">
|
||||
<figure>
|
||||
<img
|
||||
className="h-48 object-cover"
|
||||
src={thumbnail(part.imageUrl)}
|
||||
width={384}
|
||||
height={128}
|
||||
alt={part.name + ' image'}
|
||||
/>
|
||||
</figure>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title justify-between">
|
||||
<Link
|
||||
className="link-hover link w-auto overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
to={routes.partDetails({ id: part.id })}
|
||||
>
|
||||
<Link to={routes.partDetails({ id: part.id })}>
|
||||
<div className="card-compact card w-72 space-y-3 bg-base-100 font-inter shadow-xl transition-all duration-200 hover:-translate-y-2 hover:shadow-2xl sm:w-96">
|
||||
<figure>
|
||||
<img
|
||||
className="h-48 object-cover"
|
||||
src={thumbnail(part.imageUrl)}
|
||||
width={384}
|
||||
height={128}
|
||||
alt={part.name + ' image'}
|
||||
/>
|
||||
</figure>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title justify-between">
|
||||
{part.name}
|
||||
</Link>
|
||||
{part.availableStock == 0 ? (
|
||||
<div className="badge badge-error">Out of stock</div>
|
||||
) : (
|
||||
<div className="badge badge-ghost whitespace-nowrap">
|
||||
{part.availableStock + ' left'}
|
||||
</div>
|
||||
)}
|
||||
</h2>
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{part.description}
|
||||
</p>
|
||||
<div className="card-actions justify-end">
|
||||
<button
|
||||
className={`btn btn-primary ${
|
||||
part.availableStock == 0 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newBasket = addToBasket(part, 1)
|
||||
{part.availableStock == 0 ? (
|
||||
<div className="badge badge-error">Out of stock</div>
|
||||
) : (
|
||||
<div className="badge badge-ghost whitespace-nowrap">
|
||||
{part.availableStock + ' left'}
|
||||
</div>
|
||||
)}
|
||||
</h2>
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{part.description}
|
||||
</p>
|
||||
<div className="card-actions justify-end">
|
||||
<button
|
||||
className={`btn btn-primary ${
|
||||
part.availableStock == 0 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
|
||||
if (typeof newBasket == 'string')
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message={newBasket}
|
||||
/>
|
||||
))
|
||||
else
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message={`Added 1 ${part.name} to basket`}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
>
|
||||
Add to basket
|
||||
</button>
|
||||
const newBasket = addToBasket(part, 1)
|
||||
|
||||
if (typeof newBasket == 'string')
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message={newBasket}
|
||||
/>
|
||||
))
|
||||
else
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message={`Added 1 ${part.name} to basket`}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
>
|
||||
Add to basket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -13,12 +13,12 @@ const ThemeToggle = () => {
|
||||
useEffect(() => {
|
||||
themeChange(false)
|
||||
return () => {
|
||||
themeChange(false)
|
||||
themeChange(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<label className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
|
||||
<label className="swap-rotate btn btn-ghost swap w-12 hover:shadow-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={isDark}
|
||||
|
@ -35,7 +35,7 @@ const ToastNotification = ({ type, message, toast }: Props) => (
|
||||
: mdiInformation
|
||||
}
|
||||
/>
|
||||
<p className="font-inter">{message}</p>
|
||||
<p className="m-3 ml-0 font-inter">{message}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
@ -0,0 +1,25 @@
|
||||
// Pass props to your component by passing an `args` object to your story
|
||||
//
|
||||
// ```tsx
|
||||
// export const Primary: Story = {
|
||||
// args: {
|
||||
// propName: propValue
|
||||
// }
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See https://storybook.js.org/docs/react/writing-stories/args.
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import TransactionListItem from './TransactionListItem'
|
||||
|
||||
const meta: Meta<typeof TransactionListItem> = {
|
||||
component: TransactionListItem,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof TransactionListItem>
|
||||
|
||||
export const Primary: Story = {}
|
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import TransactionListItem from './TransactionListItem'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-components
|
||||
|
||||
describe('TransactionListItem', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<TransactionListItem />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
137
web/src/components/TransactionListItem/TransactionListItem.tsx
Normal file
137
web/src/components/TransactionListItem/TransactionListItem.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import type { Part, Transaction, TransactionType } from 'types/graphql'
|
||||
|
||||
import { useMutation } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/dist/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
|
||||
import ToastNotification from '../ToastNotification'
|
||||
|
||||
interface Props {
|
||||
transaction:
|
||||
| {
|
||||
id: number
|
||||
date: string
|
||||
parts: Prisma.JsonValue[]
|
||||
type: TransactionType
|
||||
}
|
||||
| Transaction
|
||||
returnButton?: boolean
|
||||
admin?: boolean
|
||||
}
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export const UPDATE_TRANSACTION_MUTATION = gql`
|
||||
mutation UpdateTransactionMutation($id: Int!, $userId: Int!) {
|
||||
returnTransaction(id: $id, userId: $userId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TransactionListItem = ({
|
||||
transaction,
|
||||
returnButton = true,
|
||||
admin = false,
|
||||
}: Props) => {
|
||||
const elapsedTime = dayjs(transaction.date).fromNow()
|
||||
|
||||
const [returnTransaction, { loading }] = useMutation(
|
||||
UPDATE_TRANSACTION_MUTATION,
|
||||
{
|
||||
onCompleted: () => {
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message="Transaction updated"
|
||||
/>
|
||||
))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.custom((t) => (
|
||||
<ToastNotification toast={t} type="error" message={error.message} />
|
||||
))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { currentUser } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="collapse-arrow collapse max-w-5xl bg-base-200 font-inter">
|
||||
<input type="checkbox" />
|
||||
<div className="collapse-title text-xl font-medium">
|
||||
<div className="items-center justify-between space-x-3 space-y-3 sm:flex sm:space-y-0">
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{transaction.parts.length} items
|
||||
</p>
|
||||
<div className="flex w-fit space-x-3">
|
||||
{admin ? (
|
||||
<div className="badge whitespace-nowrap bg-base-300 sm:badge-lg">{`${
|
||||
(transaction as Transaction).user?.firstName
|
||||
} ${(transaction as Transaction).user?.lastName}`}</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div
|
||||
className={`badge sm:badge-lg ${
|
||||
transaction.type == 'out' ? 'badge-error' : 'badge-success'
|
||||
}`}
|
||||
>
|
||||
{transaction.type.replace(
|
||||
transaction.type[0],
|
||||
transaction.type[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="badge whitespace-nowrap bg-base-300 sm:badge-lg">
|
||||
{elapsedTime.replace(
|
||||
elapsedTime[0],
|
||||
elapsedTime[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collapse-content space-y-3">
|
||||
<ul className="mx-4 list-disc space-y-2">
|
||||
{transaction.parts.map((raw) => {
|
||||
const part = JSON.parse(raw['part']) as Part
|
||||
const quantity = raw['quantity']
|
||||
return (
|
||||
<li className="list-item" key={part.id}>
|
||||
<div className="flex justify-between space-x-3">
|
||||
<p>{part.name}</p>
|
||||
<div className="badge badge-info">
|
||||
<p>Quantity: {quantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{transaction.type == 'out' && returnButton ? (
|
||||
<button
|
||||
className={`btn btn-primary ${loading ? 'btn-disabled' : ''}`}
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
returnTransaction({
|
||||
variables: { id: transaction.id, userId: currentUser.id },
|
||||
})
|
||||
}
|
||||
>
|
||||
Return
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransactionListItem
|
@ -0,0 +1,4 @@
|
||||
// Define your own mock data here:
|
||||
export const standard = (/* vars, { ctx, req } */) => ({
|
||||
userTransactions: [{ id: 42 }, { id: 43 }, { id: 44 }],
|
||||
})
|
@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
|
||||
import { standard } from './UserTransactionsCell.mock'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Cells/UserTransactionsCell',
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const loading: StoryObj<typeof Loading> = {
|
||||
render: () => {
|
||||
return Loading ? <Loading /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const empty: StoryObj<typeof Empty> = {
|
||||
render: () => {
|
||||
return Empty ? <Empty /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const failure: StoryObj<typeof Failure> = {
|
||||
render: (args) => {
|
||||
return Failure ? <Failure error={new Error('Oh no')} {...args} /> : <></>
|
||||
},
|
||||
}
|
||||
|
||||
export const success: StoryObj<typeof Success> = {
|
||||
render: (args) => {
|
||||
return Success ? <Success {...standard()} {...args} /> : <></>
|
||||
},
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
import { Loading, Empty, Failure, Success } from './UserTransactionsCell'
|
||||
import { standard } from './UserTransactionsCell.mock'
|
||||
|
||||
// Generated boilerplate tests do not account for all circumstances
|
||||
// and can fail without adjustments, e.g. Float and DateTime types.
|
||||
// Please refer to the RedwoodJS Testing Docs:
|
||||
// https://redwoodjs.com/docs/testing#testing-cells
|
||||
// https://redwoodjs.com/docs/testing#jest-expect-type-considerations
|
||||
|
||||
describe('UserTransactionsCell', () => {
|
||||
it('renders Loading successfully', () => {
|
||||
expect(() => {
|
||||
render(<Loading />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Empty successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Empty />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('renders Failure successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Failure error={new Error('Oh no')} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
// When you're ready to test the actual output of your component render
|
||||
// you could test that, for example, certain text is present:
|
||||
//
|
||||
// 1. import { screen } from '@redwoodjs/testing/web'
|
||||
// 2. Add test: expect(screen.getByText('Hello, world')).toBeInTheDocument()
|
||||
|
||||
it('renders Success successfully', async () => {
|
||||
expect(() => {
|
||||
render(<Success userTransactions={standard().userTransactions} />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
@ -0,0 +1,97 @@
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-tabindex */
|
||||
import { mdiAlert } from '@mdi/js'
|
||||
import { Icon } from '@mdi/react'
|
||||
import type { UserTransactionsQuery } from 'types/graphql'
|
||||
|
||||
import { Link, routes } from '@redwoodjs/router'
|
||||
import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web'
|
||||
|
||||
import TransactionListItem from '../TransactionListItem/TransactionListItem'
|
||||
|
||||
export const beforeQuery = ({ filter, userId }) => {
|
||||
userId = userId ?? null
|
||||
filter = filter && ['both', 'in', 'out'].includes(filter) ? filter : 'both'
|
||||
|
||||
return { variables: { filter, userId } }
|
||||
}
|
||||
|
||||
export const QUERY = gql`
|
||||
query UserTransactionsQuery(
|
||||
$userId: Int!
|
||||
$filter: FilterTransactionsByType!
|
||||
) {
|
||||
userTransactions(userId: $userId, filter: $filter) {
|
||||
transactions {
|
||||
id
|
||||
date
|
||||
parts
|
||||
type
|
||||
}
|
||||
filter
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const Loading = () => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<p className="loading loading-bars loading-lg" />
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Empty = () => (
|
||||
<div className="flex">
|
||||
<div className="alert w-auto">
|
||||
<p className="text-center font-inter">It's empty in here...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Failure = ({ error }: CellFailureProps) => (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">Error! {error?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Success = ({
|
||||
userTransactions,
|
||||
}: CellSuccessProps<UserTransactionsQuery>) => {
|
||||
if (userTransactions.transactions.length == 0) return Empty()
|
||||
return (
|
||||
<div className="flex flex-col space-y-6 font-inter">
|
||||
<div className="dropdown">
|
||||
<label tabIndex={0} className="btn m-1 normal-case">
|
||||
Filter:{' '}
|
||||
{userTransactions.filter == 'both'
|
||||
? 'None'
|
||||
: userTransactions.filter.replace(
|
||||
userTransactions.filter[0],
|
||||
userTransactions.filter[0].toUpperCase()
|
||||
)}
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content rounded-box z-10 mt-3 w-auto space-y-3 bg-base-100 p-3 shadow"
|
||||
>
|
||||
{['None', 'In', 'Out'].map((filter) => (
|
||||
<li key={filter}>
|
||||
<Link
|
||||
className="btn btn-ghost w-full normal-case"
|
||||
to={routes.userTransactions({
|
||||
filter: filter === 'None' ? 'both' : filter.toLowerCase(),
|
||||
})}
|
||||
>
|
||||
{filter}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{userTransactions.transactions.map((item, i) => {
|
||||
return <TransactionListItem transaction={item} key={i} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -7,6 +7,7 @@ import { Link, routes } from '@redwoodjs/router'
|
||||
import { Toaster } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import AdminMenu from 'src/components/AdminMenu/AdminMenu'
|
||||
import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon'
|
||||
import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle'
|
||||
import { getBasket } from 'src/lib/basket'
|
||||
@ -38,20 +39,16 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-auto justify-end space-x-3">
|
||||
{hasRole('admin') ? (
|
||||
<ul className="relative hidden items-center space-x-3 lg:flex">
|
||||
<li>
|
||||
<Link
|
||||
to={routes.parts()}
|
||||
className="btn btn-ghost font-inter hover:shadow-lg"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<ul className="relative hidden items-center space-x-3 lg:flex">
|
||||
<li>
|
||||
<Link
|
||||
to={routes.userTransactions()}
|
||||
className="btn btn-ghost font-inter hover:shadow-lg"
|
||||
>
|
||||
Transactions
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
to={routes.basket()}
|
||||
@ -70,6 +67,7 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
</div>
|
||||
</Link>
|
||||
<NavbarAccountIcon mobile={false} className="hidden lg:block" />
|
||||
<AdminMenu mobile={false} className="hidden lg:block" />
|
||||
<div className="lg:hidden">
|
||||
<input
|
||||
id="mobile-menu-drawer"
|
||||
@ -92,7 +90,11 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
<ul className="min-h-full w-80 space-y-3 bg-base-100 p-3 text-base-content shadow-lg">
|
||||
<li>
|
||||
<div className="flex items-center justify-between">
|
||||
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
|
||||
{hasRole('admin') ? (
|
||||
<AdminMenu mobile={true} />
|
||||
) : (
|
||||
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
|
||||
)}
|
||||
<Link
|
||||
to={routes.home()}
|
||||
className="btn btn-ghost items-center hover:shadow-lg"
|
||||
@ -104,18 +106,6 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
<NavbarAccountIcon mobile={true} />
|
||||
</div>
|
||||
</li>
|
||||
{hasRole('admin') ? (
|
||||
<li>
|
||||
<Link
|
||||
to={routes.parts()}
|
||||
className="btn btn-ghost w-full hover:shadow-lg"
|
||||
>
|
||||
<p className="font-inter text-base">Parts</p>
|
||||
</Link>
|
||||
</li>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<li>
|
||||
<Link
|
||||
to={routes.basket()}
|
||||
@ -124,6 +114,14 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
|
||||
<p className="font-inter text-base">Basket</p>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to={routes.userTransactions()}
|
||||
className="btn btn-ghost w-full hover:shadow-lg"
|
||||
>
|
||||
<p className="font-inter text-base">Transactions</p>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -7,8 +7,8 @@ import { Toaster } from '@redwoodjs/web/toast'
|
||||
type LayoutProps = {
|
||||
title: string
|
||||
titleTo: string
|
||||
buttonLabel: string
|
||||
buttonTo: string
|
||||
buttonLabel?: string
|
||||
buttonTo?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@ -24,16 +24,20 @@ const ScaffoldLayout = ({
|
||||
<Toaster />
|
||||
<header className="rw-header">
|
||||
<div className="space-x-3">
|
||||
<Link to={routes.home()} className="btn btn-ghost">
|
||||
<Link to={routes.home()} className="btn btn-ghost hover:shadow-lg">
|
||||
<Icon path={mdiHome} className="h-8 w-8" />
|
||||
</Link>
|
||||
<h1 className="rw-heading rw-heading-primary rw-button btn-ghost normal-case">
|
||||
<Link to={routes[titleTo]()}>{title}</Link>
|
||||
</h1>
|
||||
</div>
|
||||
<Link to={routes[buttonTo]()} className="rw-button btn-success">
|
||||
<div className="rw-button-icon">+</div> {buttonLabel}
|
||||
</Link>
|
||||
{buttonLabel && buttonTo ? (
|
||||
<Link to={routes[buttonTo]()} className="rw-button btn-success">
|
||||
<div className="rw-button-icon">+</div> {buttonLabel}
|
||||
</Link>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</header>
|
||||
<main className="rw-main">{children}</main>
|
||||
</div>
|
||||
|
@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import AdminTransactionsPage from './AdminTransactionsPage'
|
||||
|
||||
const meta: Meta<typeof AdminTransactionsPage> = {
|
||||
component: AdminTransactionsPage,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof AdminTransactionsPage>
|
||||
|
||||
export const Primary: Story = {}
|
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import AdminTransactionsPage from './AdminTransactionsPage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('AdminTransactionsPage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<AdminTransactionsPage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
@ -0,0 +1,50 @@
|
||||
import { mdiAlert } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { FilterTransactionsByType } from 'types/graphql'
|
||||
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import AdminTransactionsCell from 'src/components/AdminTransactionsCell'
|
||||
|
||||
interface Props {
|
||||
filter: FilterTransactionsByType
|
||||
}
|
||||
|
||||
const AdminTransactionsPage = ({ filter = 'both' }: Props) => {
|
||||
const { isAuthenticated, hasRole } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetaTags
|
||||
title="Admin Transactions"
|
||||
description="Admin Transactions page"
|
||||
/>
|
||||
|
||||
<div className="m-8">
|
||||
<p className="mb-8 font-inter text-3xl font-bold">Transactions</p>
|
||||
{isAuthenticated ? (
|
||||
hasRole('admin') ? (
|
||||
<AdminTransactionsCell filter={filter} />
|
||||
) : (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">You must be admin to do that.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">You must be logged in to do that.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminTransactionsPage
|
@ -1,169 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { mdiDelete, mdiMinus, mdiPlus } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
import { toast } from '@redwoodjs/web/toast'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import ToastNotification from 'src/components/ToastNotification'
|
||||
import {
|
||||
clearBasket,
|
||||
getBasket,
|
||||
removeFromBasket,
|
||||
setBasket,
|
||||
} from 'src/lib/basket'
|
||||
|
||||
const thumbnail = (url: string) => {
|
||||
if (url.includes('no_image.png')) return url
|
||||
const parts = url.split('/')
|
||||
parts.splice(3, 0, 'resize=width:160')
|
||||
return parts.join('/')
|
||||
}
|
||||
import { BasketCell } from 'src/components/BasketCell/BasketCell'
|
||||
|
||||
const BasketPage = () => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [basket, setBasketState] = useState(getBasket())
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="Basket" description="Basket page" />
|
||||
|
||||
<div className="m-8">
|
||||
<h1 className="mb-8 font-inter text-3xl font-bold">Basket</h1>
|
||||
<div className="space-y-3">
|
||||
{basket.length > 0 ? (
|
||||
basket.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex max-w-5xl items-center rounded-xl bg-base-100 shadow-xl"
|
||||
>
|
||||
<img
|
||||
alt={item.part.name}
|
||||
className="hidden h-20 w-20 rounded-l-xl object-cover sm:flex"
|
||||
src={thumbnail(item.part.imageUrl)}
|
||||
/>
|
||||
<div className="m-3 w-full items-center justify-between space-y-3 sm:flex sm:space-y-0">
|
||||
<p className="overflow-hidden text-ellipsis whitespace-nowrap font-inter text-lg font-bold">
|
||||
{item.part.name}
|
||||
</p>
|
||||
<div className="flex justify-between space-x-3">
|
||||
<div className="join">
|
||||
<button
|
||||
className={`btn join-item ${
|
||||
item.quantity == 1 ? 'btn-disabled' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newBasket = basket
|
||||
newBasket[i].quantity -= 1
|
||||
setBasketState(setBasket(newBasket))
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiMinus} className="h-6 w-6" />
|
||||
</button>
|
||||
<p className="btn join-item items-center font-inter text-lg">
|
||||
{item.quantity}
|
||||
</p>
|
||||
<button
|
||||
className={`btn join-item ${
|
||||
item.quantity == item.part.availableStock
|
||||
? 'btn-disabled'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
const newBasket = basket
|
||||
newBasket[i].quantity += 1
|
||||
setBasketState(setBasket(newBasket))
|
||||
}}
|
||||
>
|
||||
<Icon path={mdiPlus} className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost hover:shadow-lg"
|
||||
onClick={() => {
|
||||
const newBasket = removeFromBasket(i)
|
||||
|
||||
if (typeof newBasket == 'string')
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message={newBasket}
|
||||
/>
|
||||
))
|
||||
else {
|
||||
setBasketState(newBasket)
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message={`Removed ${item.part.name} from basket`}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
path={mdiDelete}
|
||||
className="h-8 w-8 text-base-content"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="alert w-auto shadow-lg">
|
||||
<p className="text-center font-inter">
|
||||
It's empty in here...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{basket.length > 0 ? (
|
||||
<div className="flex space-x-3 pt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setBasketState(clearBasket())
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="success"
|
||||
message="Basket cleared"
|
||||
/>
|
||||
))
|
||||
}}
|
||||
className="btn font-inter"
|
||||
>
|
||||
Clear basket
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isAuthenticated)
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
type="error"
|
||||
message="You must be logged in to do that"
|
||||
/>
|
||||
))
|
||||
else {
|
||||
toast.custom((t) => (
|
||||
<ToastNotification toast={t} type="info" message="Todo" />
|
||||
))
|
||||
}
|
||||
}}
|
||||
className="btn btn-primary font-inter"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
<BasketCell />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
@ -33,6 +33,7 @@ const ForgotPasswordPage = () => {
|
||||
// The function `forgotPassword.handler` in api/src/functions/auth.js has
|
||||
// been invoked, let the user know how to get the link to reset their
|
||||
// password (sent in email, perhaps?)
|
||||
// TODO: forgot password
|
||||
toast.custom((t) => (
|
||||
<ToastNotification
|
||||
toast={t}
|
||||
|
@ -17,7 +17,7 @@ const HomePage = ({ page = 1, sort = 'id', order = 'ascending' }: Props) => {
|
||||
|
||||
<div className="my-16 text-center font-inter md:my-24">
|
||||
<h1 className="text-4xl font-extrabold tracking-wide md:text-6xl">
|
||||
Arduino Parts Inventory
|
||||
Parts Inventory
|
||||
</h1>
|
||||
<p className="pt-4 text-xl">Only take what you need</p>
|
||||
</div>
|
||||
|
13
web/src/pages/TransactionsPage/TransactionsPage.stories.tsx
Normal file
13
web/src/pages/TransactionsPage/TransactionsPage.stories.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
import TransactionsPage from './TransactionsPage'
|
||||
|
||||
const meta: Meta<typeof TransactionsPage> = {
|
||||
component: TransactionsPage,
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof TransactionsPage>
|
||||
|
||||
export const Primary: Story = {}
|
14
web/src/pages/TransactionsPage/TransactionsPage.test.tsx
Normal file
14
web/src/pages/TransactionsPage/TransactionsPage.test.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { render } from '@redwoodjs/testing/web'
|
||||
|
||||
import TransactionsPage from './TransactionsPage'
|
||||
|
||||
// Improve this test with help from the Redwood Testing Doc:
|
||||
// https://redwoodjs.com/docs/testing#testing-pages-layouts
|
||||
|
||||
describe('TransactionsPage', () => {
|
||||
it('renders successfully', () => {
|
||||
expect(() => {
|
||||
render(<TransactionsPage />)
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
42
web/src/pages/TransactionsPage/TransactionsPage.tsx
Normal file
42
web/src/pages/TransactionsPage/TransactionsPage.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { mdiAlert } from '@mdi/js'
|
||||
import Icon from '@mdi/react'
|
||||
import { FilterTransactionsByType } from 'types/graphql'
|
||||
|
||||
import { MetaTags } from '@redwoodjs/web'
|
||||
|
||||
import { useAuth } from 'src/auth'
|
||||
import UserTransactionsCell from 'src/components/UserTransactionsCell'
|
||||
|
||||
interface Props {
|
||||
filter: FilterTransactionsByType
|
||||
}
|
||||
|
||||
const TransactionsPage = ({ filter = 'both' }: Props) => {
|
||||
const { isAuthenticated, currentUser } = useAuth()
|
||||
return (
|
||||
<>
|
||||
<MetaTags title="Transactions" description="Transactions page" />
|
||||
|
||||
<div className="m-8">
|
||||
<h1 className="mb-8 font-inter text-3xl font-bold">
|
||||
Transaction History
|
||||
</h1>
|
||||
{isAuthenticated ? (
|
||||
<UserTransactionsCell
|
||||
filter={filter}
|
||||
userId={currentUser ? currentUser.id : -1}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex w-auto justify-center">
|
||||
<div className="alert alert-error w-auto">
|
||||
<Icon path={mdiAlert} className="h-6 w-6" />
|
||||
<p className="font-inter">You must be logged in to do that.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransactionsPage
|
@ -5,9 +5,6 @@
|
||||
.rw-scaffold h2 {
|
||||
@apply m-0;
|
||||
}
|
||||
.rw-scaffold ul {
|
||||
@apply m-0 p-0;
|
||||
}
|
||||
.rw-scaffold input:-ms-input-placeholder {
|
||||
@apply text-gray-500;
|
||||
}
|
||||
|
Reference in New Issue
Block a user