diff --git a/app/Http/Controllers/CartController.php b/app/Http/Controllers/CartController.php index 3d58ca4..dde95e6 100644 --- a/app/Http/Controllers/CartController.php +++ b/app/Http/Controllers/CartController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Inertia\Inertia; +use Illuminate\Support\Carbon; class CartController extends Controller { @@ -94,6 +95,7 @@ function checkout(Request $request) } $transaction->total_price = $total_price; + $transaction->created_at = Carbon::now('Asia/Jakarta'); $transaction->save(); for($i = 0; $i < count($validatedCart['product_id']); $i++) { @@ -105,7 +107,7 @@ function checkout(Request $request) DB::commit(); - return redirect()->back(); + return redirect()->intended(route('transaction.index')); } catch (\Exception $e) { DB::rollBack(); throw $e; diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php new file mode 100644 index 0000000..70c0849 --- /dev/null +++ b/app/Http/Controllers/TransactionController.php @@ -0,0 +1,49 @@ +query('category_id', []); + $startDate = $request->query('start_date'); + $endDate = $request->query('end_date'); + + $category = Category::all(); + + $transaction = Transaction::with(['cart_product.product.category']) + ->whereHas('cart_product.product.category', function ($query) use ($categoryIds) { + if (!empty($categoryIds)) { + $query->whereIn('category_id', $categoryIds); + } + }) + ->whereHas('cart_product', function ($query) { + $query->where('user_id', Auth::id()); + }); + + if ($startDate && $endDate) { + $transaction = $transaction->whereBetween('created_at', [ + Carbon::parse($startDate)->startOfDay(), + Carbon::parse($endDate)->endOfDay() + ]); + } + + $transaction = $transaction->orderBy('created_at', 'desc')->get(); + + return Inertia::render('Transaction/Index', [ + 'transaction' => $transaction, + 'category' => $category, + 'categoryIds' => $categoryIds, + 'startDate' => $startDate, + 'endDate' => $endDate, + ]); + } +} diff --git a/package.json b/package.json index 750ab52..5301b80 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-popover": "^1.1.2", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lodash.get": "^4.4.2", "lucide-react": "^0.451.0", "react-datepicker": "^7.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c7f638..6053135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lodash.get: specifier: ^4.4.2 version: 4.4.2 @@ -1263,6 +1266,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -3468,6 +3474,8 @@ snapshots: date-fns@3.6.0: {} + date-fns@4.1.0: {} + debug@4.3.7: dependencies: ms: 2.1.3 diff --git a/resources/js/Components/Accordion.tsx b/resources/js/Components/Accordion.tsx index 720cfa9..12ed2a3 100644 --- a/resources/js/Components/Accordion.tsx +++ b/resources/js/Components/Accordion.tsx @@ -24,7 +24,7 @@ const AccordionTrigger = React.forwardRef< - + {children} diff --git a/resources/js/Components/DatepickerFilter.tsx b/resources/js/Components/DatepickerFilter.tsx new file mode 100644 index 0000000..f3f1198 --- /dev/null +++ b/resources/js/Components/DatepickerFilter.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { FormProvider, useForm, useWatch } from "react-hook-form"; +import RangeDatePicker from "./Forms/RangeDatepicker"; + +export type PopupFilterProps = { + date: Date[]; + setDate: React.Dispatch>; + onResetFilter?: () => void; +} & React.ComponentPropsWithoutRef<"div">; + +type FormData = { + filter: Date[]; +}; + +export default function DatepickerFilter({ + date, + setDate, + onResetFilter, +}: PopupFilterProps) { + //#region //*=========== Form ===========t + const methods = useForm({ + mode: "onTouched", + defaultValues: { + filter: date, + }, + }); + const { control, setValue } = methods; + + const filter = + useWatch({ + control, + name: "filter", + }) ?? []; + //#endregion //*======== Form =========== + + React.useEffect(() => { + setDate(filter); + }, [filter]); + + const resetFilter = () => { + onResetFilter?.(); + setValue("filter", []); + }; + + return ( + +
+ 0} + onClearDate={resetFilter} + /> +
+
+ ); +} diff --git a/resources/js/Components/Forms/RangeDatepicker.tsx b/resources/js/Components/Forms/RangeDatepicker.tsx index b4b06be..1bf1df6 100644 --- a/resources/js/Components/Forms/RangeDatepicker.tsx +++ b/resources/js/Components/Forms/RangeDatepicker.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import get from "lodash.get"; -import { Calendar } from "lucide-react"; +import { Calendar, XCircle } from "lucide-react"; import { Controller, RegisterOptions, useFormContext } from "react-hook-form"; import "react-datepicker/dist/react-datepicker.css"; @@ -16,12 +16,12 @@ type ReactDatePickerProps = { placeholder?: string; defaultYear?: number; defaultMonth?: number; - defaultValue?: string; helperText?: string; readOnly?: boolean; /** Disable error style (not disabling error validation) */ hideError?: boolean; containerClassName?: string; + onClearDate?: () => void; } & Omit; export default function RangeDatePicker({ @@ -31,11 +31,12 @@ export default function RangeDatePicker({ placeholder, defaultYear, defaultMonth, - defaultValue, helperText, readOnly = false, hideError = false, disabled, + isClearable, + onClearDate, containerClassName, }: ReactDatePickerProps) { const { @@ -76,7 +77,6 @@ export default function RangeDatePicker({ { const startDate = Array.isArray(value) @@ -100,7 +100,7 @@ export default function RangeDatePicker({ endDate={endDate} className={clsx( "flex w-full rounded-lg shadow-sm", - "min-h-[2.25rem] py-0 md:min-h-[2.5rem]", + "min-h-[2.5rem] py-0 md:min-h-[2.75rem]", "border-gray-300 focus:border-primary-500 focus:ring-primary-500", (readOnly || disabled) && "cursor-not-allowed border-gray-300 bg-gray-100 focus:border-gray-300 focus:ring-0", @@ -118,10 +118,14 @@ export default function RangeDatePicker({ disabled={disabled} selectsRange /> - +
+ {isClearable && ( + + )} + +
{helperText && ( diff --git a/resources/js/Pages/Product/Components/PopupFilter.tsx b/resources/js/Components/PopupFilter.tsx similarity index 83% rename from resources/js/Pages/Product/Components/PopupFilter.tsx rename to resources/js/Components/PopupFilter.tsx index a56b00d..a1a73e9 100644 --- a/resources/js/Pages/Product/Components/PopupFilter.tsx +++ b/resources/js/Components/PopupFilter.tsx @@ -20,25 +20,43 @@ export type PopupFilterProps> = { name: string; }[]; }[]; + filterQuery: T; setFilterQuery: React.Dispatch>; + onResetFilter?: () => void; title?: string; } & React.ComponentPropsWithoutRef<"div">; +type FormData = { + filter: string[]; +}; + export default function PopupFilter>({ filterOption, + filterQuery, setFilterQuery, + onResetFilter, title = "Filter", }: PopupFilterProps) { //#region //*=========== Form =========== - const methods = useForm({ + const defaultFilterValues = React.useMemo(() => { + return Object.entries(filterQuery).reduce((acc, [key, values]) => { + return [...acc, ...values.map((value) => `${key}.${value}`)]; + }, [] as string[]); + }, [filterQuery]); + + const methods = useForm({ mode: "onTouched", + defaultValues: { + filter: defaultFilterValues, + }, }); const { control, setValue } = methods; - const filter: string[] = useWatch({ - control, - name: "filter[]", - }); + const filter = + useWatch({ + control, + name: "filter", + }) ?? []; //#endregion //*======== Form =========== React.useEffect(() => { @@ -56,7 +74,10 @@ export default function PopupFilter>({ setFilterQuery(parsedFilter); }, [filter, filterOption, setFilterQuery]); - const resetFilter = () => setValue("filter[]", []); + const resetFilter = () => { + onResetFilter?.(); + setValue("filter", []); + }; return ( diff --git a/resources/js/Components/UnstyledLink.tsx b/resources/js/Components/UnstyledLink.tsx index 54f0c8d..ddf649f 100644 --- a/resources/js/Components/UnstyledLink.tsx +++ b/resources/js/Components/UnstyledLink.tsx @@ -12,7 +12,14 @@ export type UnstyledLinkProps = { const UnstyledLink = React.forwardRef( ( - { children, href, openNewTab, className, inertiaLinkProps, ...rest }, + { + children, + href, + openNewTab = false, + className, + inertiaLinkProps, + ...rest + }, ref, ) => { const isNewTab = diff --git a/resources/js/Layouts/Navbar.tsx b/resources/js/Layouts/Navbar.tsx index 1dbcc68..79ceeb7 100644 --- a/resources/js/Layouts/Navbar.tsx +++ b/resources/js/Layouts/Navbar.tsx @@ -44,6 +44,14 @@ export default function Navbar() { > Cart + )}{" "} + {auth.user !== null && auth.user.role === "user" && ( + + Transaction + )} diff --git a/resources/js/Pages/Cart/Index.tsx b/resources/js/Pages/Cart/Index.tsx index 7b3a207..c7e39ce 100644 --- a/resources/js/Pages/Cart/Index.tsx +++ b/resources/js/Pages/Cart/Index.tsx @@ -27,7 +27,13 @@ const CartIndex = ({ cart }: { cart: CartType[] }) => { const readyToCheckout = cart.filter((item) => checkedOut.includes(item.id.toString())) ?? []; - transform((data) => ({ ...data, ...getValues() })); + transform((data) => ({ + ...data, + product_id: + typeof getValues("product_id") === "string" + ? [getValues("product_id")] + : getValues("product_id"), + })); const onSubmit: SubmitHandler = async () => { post(route("cart.checkout"), { diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index aa95c6c..b29f3c5 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -9,7 +9,7 @@ import { Link, usePage } from "@inertiajs/react"; import { Head } from "@inertiajs/react"; import { MapPin, Plus, Search, XCircle } from "lucide-react"; import * as React from "react"; -import PopupFilter, { PopupFilterProps } from "./Components/PopupFilter"; +import PopupFilter, { PopupFilterProps } from "../../Components/PopupFilter"; type CategoryFilter = { category: string[]; @@ -65,8 +65,8 @@ const ProductIndex = ({ All Product -
-
+
+
@@ -97,6 +97,7 @@ const ProductIndex = ({
@@ -106,12 +107,12 @@ const ProductIndex = ({ openNewTab={false} leftIcon={Plus} > - Add Product + Product )}
-
+
{productList.map((p) => (
-
+
(""); + const [filterDate, setFilterDate] = React.useState( + [ + startDate ? new Date(startDate) : null, + endDate ? new Date(endDate) : null, + ].filter(Boolean) as Date[], + ); + const [filterQuery, setFilterQuery] = React.useState({ + category: categoryIds, + }); + + const prevCategory = React.useRef(categoryIds); + const prevDate = React.useRef(filterDate); + + const filterOption: PopupFilterProps["filterOption"] = + React.useMemo( + () => [ + { + id: "category", + name: "Category", + options: category, + }, + ], + [category], + ); + + const filteredTransaction = React.useMemo(() => { + return transaction.filter((t) => { + const matchesText = t.cart_product.some((cp) => + cp.product.name.toLowerCase().includes(filter.toLowerCase()), + ); + return matchesText; + }); + }, [transaction, filter]); + + const handleFilterChange = React.useCallback( + (e: React.ChangeEvent) => { + setFilter(String(e.target.value)); + }, + [], + ); + + const handleClearFilter = React.useCallback(() => { + setFilter(""); + }, []); + + React.useEffect(() => { + if ( + JSON.stringify(filterQuery.category) !== + JSON.stringify(prevCategory.current) || + (JSON.stringify(filterDate) !== JSON.stringify(prevDate.current) && + !(!filterDate[0] !== !filterDate[1])) + ) { + const query: QueryType = { + category_id: filterQuery.category, + }; + if (filterDate[0] && filterDate[1]) { + query.start_date = formatDate(filterDate[0], "yyyy-MM-dd"); + query.end_date = formatDate(filterDate[1], "yyyy-MM-dd"); + } + router.get("/history", query, { + preserveState: true, + preserveScroll: true, + }); + prevCategory.current = filterQuery.category; + prevDate.current = filterDate; + } + }, [filterQuery.category, filterDate]); + + return ( + + +
+ + Order List + +
+
+
+ +
+ + {filter !== "" && ( +
+ +
+ )} +
+
+ setFilterDate([])} + /> + setFilterQuery({ category: [] })} + /> +
+
+ {filteredTransaction.length === 0 ? ( + + ) : ( +
+ {filteredTransaction.map((t) => ( +
+
+ + {format(new Date(t.created_at), "dd MMMM yyyy")} + + + | + + + TR/{format(new Date(t.created_at), "yyyyMMdd")}/{t.id} + +
+
+ + {t.cart_product.length > 1 && ( + + + + {t.cart_product.map( + (cp, index) => + index !== 0 && ( + + ), + )} + + + + Show more items + + + + + Show less items + + + + + )} +
+
+
+ + Grand Total: + + + {numberToCurrency(t.total_price)} + +
+
+
+
+ ))} +
+ )} +
+
+ ); +} + +type ProductItemsProps = { + cart_product: CartType; + quantity: number; +}; + +function ProductItems({ cart_product: cp, quantity }: ProductItemsProps) { + return ( +
+
+ {cp.product.name} +
+ + {cp.product.name} + +
+ {cp.product.category.map((c) => ( + + {c.name} + + ))} +
+ + {quantity} item{quantity > 1 && "s"} x{" "} + {numberToCurrency(cp.product.price)} + +
+
+
+ + Total Price + + + {numberToCurrency(cp.product.price * cp.quantity)} + +
+
+ ); +} diff --git a/resources/js/Pages/Transaction/container/NotFound.tsx b/resources/js/Pages/Transaction/container/NotFound.tsx new file mode 100644 index 0000000..5ce2252 --- /dev/null +++ b/resources/js/Pages/Transaction/container/NotFound.tsx @@ -0,0 +1,62 @@ +import ButtonLink from "@/Components/ButtonLink"; +import Typography from "@/Components/Typography"; + +export default function NotFound() { + return ( +
+ + + + + + + + + + + +
+ + No Orders Yet + + + Your order history is looking a bit empty. Start exploring our amazing + products and make your first purchase! + +
+ + Shop Now + +
+ ); +} diff --git a/resources/js/types/entities/transaction.ts b/resources/js/types/entities/transaction.ts new file mode 100644 index 0000000..a96b313 --- /dev/null +++ b/resources/js/types/entities/transaction.ts @@ -0,0 +1,8 @@ +import { CartType } from "./cart"; + +export type TransactionType = { + id: string; + total_price: number; + cart_product: CartType[]; + created_at: Date; +}; diff --git a/routes/web.php b/routes/web.php index 1b50b57..a4be13d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,7 +3,7 @@ use App\Http\Controllers\CartController; use App\Http\Controllers\ProductController; use App\Http\Controllers\ProfileController; -use Illuminate\Foundation\Application; +use App\Http\Controllers\TransactionController; use Illuminate\Support\Facades\Route; use Inertia\Inertia; use Illuminate\Support\Facades\Auth; @@ -20,7 +20,7 @@ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, - 'role' => $user->role, + 'role' => $user->role, ] : null, ], ]); @@ -31,7 +31,7 @@ Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); - + Route::prefix('/product')->group(function () { Route::get('/', [ProductController::class, 'index'])->name('product.index'); Route::get('/{product}/detail', [ProductController::class, 'show'])->name('product.show'); @@ -39,12 +39,14 @@ }); Route::middleware('auth', 'role:user')->group(function () { - Route::prefix('/cart')->group(function() { + Route::prefix('/cart')->group(function () { Route::get('/', [CartController::class, 'viewCart'])->name('cart.index'); Route::post('/add/{product}', [CartController::class, 'addToCart'])->name('cart.add'); Route::delete('/{id}/delete', [CartController::class, 'deleteFromCart'])->name('cart.delete'); Route::post('/checkout', [CartController::class, 'checkout'])->name('cart.checkout'); }); + + Route::get('/history', [TransactionController::class, 'index'])->name('transaction.index'); }); Route::middleware('auth', 'role:admin')->group(function () { @@ -57,4 +59,4 @@ }); }); -require __DIR__.'/auth.php'; +require __DIR__ . '/auth.php';