From fd20a3531b422047f0b734d57ce48fb00fdc5524 Mon Sep 17 00:00:00 2001 From: louiscklaw Date: Fri, 30 May 2025 16:48:54 +0800 Subject: [PATCH] "feat: enhance invoice management with schema updates, seed data, and new APIs" --- 03_source/cms_backend/package.json | 2 +- 03_source/cms_backend/prisma/schema.prisma | 59 ++-- 03_source/cms_backend/prisma/seed.ts | 2 + 03_source/cms_backend/prisma/seeds/_others.ts | 187 +++++++++++++ .../cms_backend/prisma/seeds/invoiceItem.ts | 76 ++++++ .../prisma/seeds/utils/format-time.ts | 257 ++++++++++++++++++ .../src/app/api/invoice/changeStatus/route.ts | 98 +++++++ .../app/api/invoice/changeStatus/test.http | 9 + .../src/app/api/invoice/createUser/route.ts | 53 ++++ .../src/app/api/invoice/createUser/test.http | 4 + .../src/app/api/invoice/deleteUser/route.ts | 47 ++++ .../src/app/api/invoice/deleteUser/test.http | 3 + .../src/app/api/invoice/details/route.ts | 47 ++++ .../src/app/api/invoice/details/test.http | 4 + .../src/app/api/invoice/image/upload/route.ts | 30 ++ .../src/app/api/invoice/list/route.ts | 22 ++ .../src/app/api/invoice/list/test.http | 3 + .../src/app/api/invoice/saveInvoice/route.ts | 98 +++++++ .../src/app/api/invoice/saveInvoice/test.http | 58 ++++ .../src/app/api/invoice/search/route.ts | 37 +++ .../src/app/api/product/saveProduct/route.ts | 4 +- 03_source/frontend/prettier.config.mjs | 3 +- 03_source/frontend/src/actions/invoice.ts | 221 +++++++++++++++ 03_source/frontend/src/actions/product.ts | 2 +- .../frontend/src/locales/langs/hk/common.json | 26 +- .../src/pages/dashboard/invoice/details.tsx | 7 +- .../src/pages/dashboard/invoice/edit.tsx | 4 +- .../src/pages/dashboard/invoice/list.tsx | 2 + .../src/pages/dashboard/product/details.tsx | 2 +- .../src/pages/dashboard/product/edit.tsx | 4 +- .../src/pages/dashboard/product/list.tsx | 2 + .../frontend/src/pages/product/details.tsx | 2 +- .../src/sections/invoice/invoice-details.tsx | 22 +- .../invoice/invoice-new-edit-form.tsx | 81 +++--- .../invoice/invoice-new-edit-status-date.tsx | 4 +- .../src/sections/invoice/invoice-pdf.tsx | 10 +- .../sections/invoice/invoice-table-row.tsx | 19 +- .../invoice/invoice-table-toolbar.tsx | 29 +- .../src/sections/invoice/invoice-toolbar.tsx | 23 +- .../invoice/view/invoice-details-view.tsx | 20 +- .../invoice/view/invoice-edit-view.tsx | 6 +- .../invoice/view/invoice-list-view.tsx | 81 +++--- .../sections/order/order-details-delivery.tsx | 10 +- .../sections/order/order-details-items.tsx | 2 +- .../product/product-new-edit-form.tsx | 5 +- .../sections/product/product-table-row.tsx | 2 - .../product/view/product-list-view.tsx | 23 +- 03_source/frontend/src/types/invoice.ts | 8 +- 48 files changed, 1541 insertions(+), 179 deletions(-) create mode 100644 03_source/cms_backend/prisma/seeds/_others.ts create mode 100644 03_source/cms_backend/prisma/seeds/invoiceItem.ts create mode 100644 03_source/cms_backend/prisma/seeds/utils/format-time.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/changeStatus/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/changeStatus/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/createUser/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/createUser/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/deleteUser/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/deleteUser/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/details/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/details/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/image/upload/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/list/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/list/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/saveInvoice/route.ts create mode 100644 03_source/cms_backend/src/app/api/invoice/saveInvoice/test.http create mode 100644 03_source/cms_backend/src/app/api/invoice/search/route.ts create mode 100644 03_source/frontend/src/actions/invoice.ts diff --git a/03_source/cms_backend/package.json b/03_source/cms_backend/package.json index 1e2be19..da4f2b0 100644 --- a/03_source/cms_backend/package.json +++ b/03_source/cms_backend/package.json @@ -27,7 +27,7 @@ "unseed": "tsx ./prisma/unseed.ts", "db:generate": "prisma generate", "db:push": "prisma db push --force-reset", - "db:push:w": "npx nodemon --delay 5 --ext \"ts,tsx,prisma\" --exec \"yarn db:push && yarn seed\"", + "db:push:w": "npx nodemon --delay 1 --watch prisma --ext \"ts,tsx,prisma\" --exec \"yarn db:push && yarn seed\"", "db:studio": "prisma studio" }, "engines": { diff --git a/03_source/cms_backend/prisma/schema.prisma b/03_source/cms_backend/prisma/schema.prisma index d092c59..e1fc617 100644 --- a/03_source/cms_backend/prisma/schema.prisma +++ b/03_source/cms_backend/prisma/schema.prisma @@ -786,8 +786,8 @@ model AddressItem { addressType String? CheckoutState CheckoutState[] checkoutStateId Int? - InvoiceTo Invoice[] @relation("invoice_to") - InvoiceFrom Invoice[] @relation("invoice_from") + // InvoiceTo Invoice[] @relation("invoice_to") + // InvoiceFrom Invoice[] @relation("invoice_from") } model SocialLink { @@ -887,40 +887,45 @@ model InvoiceTableFilters { } model InvoiceItem { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // - title String - price Float - total Float - service String - quantity Int - description String - Invoice Invoice? @relation(fields: [invoiceId], references: [id]) - invoiceId Int? -} - -model Invoice { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - // - sent Int taxes Float status String - subtotal Float discount Float shipping Float + subtotal Float totalAmount Float - dueDate DateTime + items Json[] invoiceNumber String - items InvoiceItem[] - createDate DateTime - invoiceTo AddressItem[] @relation("invoice_to") - invoiceFrom AddressItem[] @relation("invoice_from") + invoiceFrom Json + invoiceTo Json + sent Float + dueDate DateTime @default(now()) + createdDate DateTime @default(now()) } +// model Invoice { +// id Int @id @default(autoincrement()) +// createdAt DateTime @default(now()) +// updatedAt DateTime @updatedAt +// // +// sent Int +// taxes Float +// status String +// subtotal Float +// discount Float +// shipping Float +// totalAmount Float +// dueDate DateTime +// invoiceNumber String +// items InvoiceItem[] +// createDate DateTime +// invoiceTo AddressItem[] @relation("invoice_to") +// invoiceFrom AddressItem[] @relation("invoice_from") +// } + // job.ts model JobFilters { id Int @id @default(autoincrement()) diff --git a/03_source/cms_backend/prisma/seed.ts b/03_source/cms_backend/prisma/seed.ts index 7713f24..263e2c5 100644 --- a/03_source/cms_backend/prisma/seed.ts +++ b/03_source/cms_backend/prisma/seed.ts @@ -24,6 +24,7 @@ import { ProductItem } from './seeds/productItem'; import { FileStore } from './seeds/fileStore'; import { userItemSeed } from './seeds/userItem'; import { orderItemSeed } from './seeds/orderItem'; +import { invoiceItemSeed } from './seeds/invoiceItem'; // // import { Blog } from './seeds/blog'; @@ -42,6 +43,7 @@ import { orderItemSeed } from './seeds/orderItem'; await ProductItem; await userItemSeed; await orderItemSeed; + await invoiceItemSeed; // await Blog; // await Mail; // await File; diff --git a/03_source/cms_backend/prisma/seeds/_others.ts b/03_source/cms_backend/prisma/seeds/_others.ts new file mode 100644 index 0000000..14ac89c --- /dev/null +++ b/03_source/cms_backend/prisma/seeds/_others.ts @@ -0,0 +1,187 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const _carouselsMembers = Array.from({ length: 6 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + role: _mock.role(index), + avatarUrl: _mock.image.portrait(index), +})); + +// ---------------------------------------------------------------------- + +export const _faqs = Array.from({ length: 8 }, (_, index) => ({ + id: _mock.id(index), + value: `panel${index + 1}`, + heading: `Questions ${index + 1}`, + detail: _mock.description(index), +})); + +// ---------------------------------------------------------------------- + +export const _addressBooks = Array.from({ length: 24 }, (_, index) => ({ + id: _mock.id(index), + primary: index === 0, + name: _mock.fullName(index), + email: _mock.email(index + 1), + fullAddress: _mock.fullAddress(index), + phoneNumber: _mock.phoneNumber(index), + company: _mock.companyNames(index + 1), + addressType: index === 0 ? 'Home' : 'Office', +})); + +// ---------------------------------------------------------------------- + +export const _contacts = Array.from({ length: 20 }, (_, index) => { + const status = (index % 2 && 'online') || (index % 3 && 'offline') || (index % 4 && 'always') || 'busy'; + + return { + id: _mock.id(index), + status, + role: _mock.role(index), + email: _mock.email(index), + name: _mock.fullName(index), + phoneNumber: _mock.phoneNumber(index), + lastActivity: _mock.time(index), + avatarUrl: _mock.image.avatar(index), + address: _mock.fullAddress(index), + }; +}); + +// ---------------------------------------------------------------------- + +export const _notifications = Array.from({ length: 9 }, (_, index) => ({ + id: _mock.id(index), + avatarUrl: [_mock.image.avatar(1), _mock.image.avatar(2), _mock.image.avatar(3), _mock.image.avatar(4), _mock.image.avatar(5), null, null, null, null, null][ + index + ], + type: ['friend', 'project', 'file', 'tags', 'payment', 'order', 'delivery', 'chat', 'mail'][index], + category: ['Communication', 'Project UI', 'File manager', 'File manager', 'File manager', 'Order', 'Order', 'Communication', 'Communication'][index], + isUnRead: _mock.boolean(index), + createdAt: _mock.time(index), + title: + (index === 0 && `

Deja Brady sent you a friend request

`) || + (index === 1 && `

Jayvon Hull mentioned you in Minimal UI

`) || + (index === 2 && `

Lainey Davidson added file to File manager

`) || + (index === 3 && `

Angelique Morse added new tags to File manager

`) || + (index === 4 && `

Giana Brandt request a payment of $200

`) || + (index === 5 && `

Your order is placed waiting for shipping

`) || + (index === 6 && `

Delivery processing your order is being shipped

`) || + (index === 7 && `

You have new message 5 unread messages

`) || + (index === 8 && `

You have new mail`) || + '', +})); + +// ---------------------------------------------------------------------- + +export const _mapContact = [ + { latlng: [33, 65], address: _mock.fullAddress(1), phoneNumber: _mock.phoneNumber(1) }, + { latlng: [-12.5, 18.5], address: _mock.fullAddress(2), phoneNumber: _mock.phoneNumber(2) }, +]; + +// ---------------------------------------------------------------------- + +export const _socials = [ + { + value: 'facebook', + label: 'Facebook', + path: 'https://www.facebook.com/caitlyn.kerluke', + }, + { + value: 'instagram', + label: 'Instagram', + path: 'https://www.instagram.com/caitlyn.kerluke', + }, + { + value: 'linkedin', + label: 'Linkedin', + path: 'https://www.linkedin.com/caitlyn.kerluke', + }, + { + value: 'twitter', + label: 'Twitter', + path: 'https://www.twitter.com/caitlyn.kerluke', + }, +]; + +// ---------------------------------------------------------------------- + +export const _pricingPlans = [ + { + subscription: 'basic', + price: 0, + caption: 'Forever', + lists: ['3 prototypes', '3 boards', 'Up to 5 team members'], + labelAction: 'Current plan', + }, + { + subscription: 'starter', + price: 4.99, + caption: 'Saving $24 a year', + lists: ['3 prototypes', '3 boards', 'Up to 5 team members', 'Advanced security', 'Issue escalation'], + labelAction: 'Choose starter', + }, + { + subscription: 'premium', + price: 9.99, + caption: 'Saving $124 a year', + lists: [ + '3 prototypes', + '3 boards', + 'Up to 5 team members', + 'Advanced security', + 'Issue escalation', + 'Issue development license', + 'Permissions & workflows', + ], + labelAction: 'Choose premium', + }, +]; + +// ---------------------------------------------------------------------- + +export const _testimonials = [ + { + name: _mock.fullName(1), + postedDate: _mock.time(1), + ratingNumber: _mock.number.rating(1), + avatarUrl: _mock.image.avatar(1), + content: `Excellent Work! Thanks a lot!`, + }, + { + name: _mock.fullName(2), + postedDate: _mock.time(2), + ratingNumber: _mock.number.rating(2), + avatarUrl: _mock.image.avatar(2), + content: `It's a very good dashboard and we are really liking the product . We've done some things, like migrate to TS and implementing a react useContext api, to fit our job methodology but the product is one of the best in terms of design and application architecture. The team did a really good job.`, + }, + { + name: _mock.fullName(3), + postedDate: _mock.time(3), + ratingNumber: _mock.number.rating(3), + avatarUrl: _mock.image.avatar(3), + content: `Customer support is realy fast and helpful the desgin of this theme is looks amazing also the code is very clean and readble realy good job !`, + }, + { + name: _mock.fullName(4), + postedDate: _mock.time(4), + ratingNumber: _mock.number.rating(4), + avatarUrl: _mock.image.avatar(4), + content: `Amazing, really good code quality and gives you a lot of examples for implementations.`, + }, + { + name: _mock.fullName(5), + postedDate: _mock.time(5), + ratingNumber: _mock.number.rating(5), + avatarUrl: _mock.image.avatar(5), + content: `Got a few questions after purchasing the product. The owner responded very fast and very helpfull. Overall the code is excellent and works very good. 5/5 stars!`, + }, + { + name: _mock.fullName(6), + postedDate: _mock.time(6), + ratingNumber: _mock.number.rating(6), + avatarUrl: _mock.image.avatar(6), + content: `CEO of Codealy.io here. We’ve built a developer assessment platform that makes sense - tasks are based on git repositories and run in virtual machines. We automate the pain points - storing candidates code, running it and sharing test results with the whole team, remotely. Bought this template as we need to provide an awesome dashboard for our early customers. I am super happy with purchase. The code is just as good as the design. Thanks!`, + }, +]; diff --git a/03_source/cms_backend/prisma/seeds/invoiceItem.ts b/03_source/cms_backend/prisma/seeds/invoiceItem.ts new file mode 100644 index 0000000..14ad7f2 --- /dev/null +++ b/03_source/cms_backend/prisma/seeds/invoiceItem.ts @@ -0,0 +1,76 @@ +import { PrismaClient } from '@prisma/client'; + +import { _mock } from './_mock'; +import { _tags } from './assets'; +import { _addressBooks } from './_others'; +import { fSub, fAdd } from './utils/format-time'; + +const prisma = new PrismaClient(); + +export const INVOICE_SERVICE_OPTIONS = Array.from({ length: 8 }, (_, index) => ({ + id: _mock.id(index), + name: _tags[index], + price: _mock.number.price(index), +})); + +const ITEMS = Array.from({ length: 3 }, (__, index) => { + const total = INVOICE_SERVICE_OPTIONS[index].price * _mock.number.nativeS(index); + + return { + id: _mock.id(index), + total, + title: _mock.productName(index), + description: _mock.sentence(index), + price: INVOICE_SERVICE_OPTIONS[index].price, + service: INVOICE_SERVICE_OPTIONS[index].name, + quantity: _mock.number.nativeS(index), + }; +}); + +async function invoiceItem() { + await prisma.orderItem.deleteMany({}); + + for (let index = 1; index < 3 + 1; index++) { + const shipping = 10; + const discount = 10; + const taxes = 10; + const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS; + const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0); + const totalAmount = subtotal - shipping - discount + taxes; + + const temp = await prisma.invoiceItem.upsert({ + where: { id: index.toString() }, + update: {}, + create: { + id: index.toString(), + taxes, + status: (index % 2 && 'paid') || (index % 3 && 'pending') || (index % 4 && 'overdue') || 'draft', + discount, + shipping, + subtotal: items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0), + totalAmount, + items, + invoiceNumber: `INV-199${index}`, + invoiceFrom: _addressBooks[index], + invoiceTo: _addressBooks[index + 1], + sent: _mock.number.nativeS(index), + dueDate: new Date(fAdd({ days: index + 15, hours: index })), + createdDate: new Date(fAdd({ days: index + 15, hours: index })), + }, + }); + } + + console.log('seed invoiceItem done'); +} + +const invoiceItemSeed = invoiceItem() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); + +export { invoiceItemSeed }; diff --git a/03_source/cms_backend/prisma/seeds/utils/format-time.ts b/03_source/cms_backend/prisma/seeds/utils/format-time.ts new file mode 100644 index 0000000..9dcc178 --- /dev/null +++ b/03_source/cms_backend/prisma/seeds/utils/format-time.ts @@ -0,0 +1,257 @@ +import type { Dayjs, OpUnitType } from 'dayjs'; + +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import relativeTime from 'dayjs/plugin/relativeTime'; + +// ---------------------------------------------------------------------- + +/** + * @Docs + * https://day.js.org/docs/en/display/format + */ + +/** + * Default timezones + * https://day.js.org/docs/en/timezone/set-default-timezone#docsNav + * + */ + +/** + * UTC + * https://day.js.org/docs/en/plugin/utc + * @install + * import utc from 'dayjs/plugin/utc'; + * dayjs.extend(utc); + * @usage + * dayjs().utc().format() + * + */ + +dayjs.extend(duration); +dayjs.extend(relativeTime); + +// ---------------------------------------------------------------------- + +export type DatePickerFormat = Dayjs | Date | string | number | null | undefined; + +export const formatPatterns = { + dateTime: 'DD MMM YYYY h:mm a', // 17 Apr 2022 12:00 am + date: 'DD MMM YYYY', // 17 Apr 2022 + time: 'h:mm a', // 12:00 am + split: { + dateTime: 'DD/MM/YYYY h:mm a', // 17/04/2022 12:00 am + date: 'DD/MM/YYYY', // 17/04/2022 + }, + paramCase: { + dateTime: 'DD-MM-YYYY h:mm a', // 17-04-2022 12:00 am + date: 'DD-MM-YYYY', // 17-04-2022 + }, +}; + +const isValidDate = (date: DatePickerFormat) => date !== null && date !== undefined && dayjs(date).isValid(); + +// ---------------------------------------------------------------------- + +export function today(template?: string): string { + return dayjs(new Date()).startOf('day').format(template); +} + +// ---------------------------------------------------------------------- + +/** + * @output 17 Apr 2022 12:00 am + */ +export function fDateTime(date: DatePickerFormat, template?: string): string { + if (!isValidDate(date)) { + return 'Invalid date'; + } + + return dayjs(date).format(template ?? formatPatterns.dateTime); +} + +// ---------------------------------------------------------------------- + +/** + * @output 17 Apr 2022 + */ +export function fDate(date: DatePickerFormat, template?: string): string { + if (!isValidDate(date)) { + return 'Invalid date'; + } + + return dayjs(date).format(template ?? formatPatterns.date); +} + +// ---------------------------------------------------------------------- + +/** + * @output 12:00 am + */ +export function fTime(date: DatePickerFormat, template?: string): string { + if (!isValidDate(date)) { + return 'Invalid date'; + } + + return dayjs(date).format(template ?? formatPatterns.time); +} + +// ---------------------------------------------------------------------- + +/** + * @output 1713250100 + */ +export function fTimestamp(date: DatePickerFormat): number | 'Invalid date' { + if (!isValidDate(date)) { + return 'Invalid date'; + } + + return dayjs(date).valueOf(); +} + +// ---------------------------------------------------------------------- + +/** + * @output a few seconds, 2 years + */ +export function fToNow(date: DatePickerFormat): string { + if (!isValidDate(date)) { + return 'Invalid date'; + } + + return dayjs(date).toNow(true); +} + +// ---------------------------------------------------------------------- + +/** + * @output boolean + */ +export function fIsBetween(inputDate: DatePickerFormat, startDate: DatePickerFormat, endDate: DatePickerFormat): boolean { + if (!isValidDate(inputDate) || !isValidDate(startDate) || !isValidDate(endDate)) { + return false; + } + + const formattedInputDate = fTimestamp(inputDate); + const formattedStartDate = fTimestamp(startDate); + const formattedEndDate = fTimestamp(endDate); + + if (formattedInputDate === 'Invalid date' || formattedStartDate === 'Invalid date' || formattedEndDate === 'Invalid date') { + return false; + } + + return formattedInputDate >= formattedStartDate && formattedInputDate <= formattedEndDate; +} + +// ---------------------------------------------------------------------- + +/** + * @output boolean + */ +export function fIsAfter(startDate: DatePickerFormat, endDate: DatePickerFormat): boolean { + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return false; + } + + return dayjs(startDate).isAfter(endDate); +} + +// ---------------------------------------------------------------------- + +/** + * @output boolean + */ +export function fIsSame(startDate: DatePickerFormat, endDate: DatePickerFormat, unitToCompare?: OpUnitType): boolean { + if (!isValidDate(startDate) || !isValidDate(endDate)) { + return false; + } + + return dayjs(startDate).isSame(endDate, unitToCompare ?? 'year'); +} + +/** + * @output + * Same day: 26 Apr 2024 + * Same month: 25 - 26 Apr 2024 + * Same month: 25 - 26 Apr 2024 + * Same year: 25 Apr - 26 May 2024 + */ +export function fDateRangeShortLabel(startDate: DatePickerFormat, endDate: DatePickerFormat, initial?: boolean): string { + if (!isValidDate(startDate) || !isValidDate(endDate) || fIsAfter(startDate, endDate)) { + return 'Invalid date'; + } + + let label = `${fDate(startDate)} - ${fDate(endDate)}`; + + if (initial) { + return label; + } + + const isSameYear = fIsSame(startDate, endDate, 'year'); + const isSameMonth = fIsSame(startDate, endDate, 'month'); + const isSameDay = fIsSame(startDate, endDate, 'day'); + + if (isSameYear && !isSameMonth) { + label = `${fDate(startDate, 'DD MMM')} - ${fDate(endDate)}`; + } else if (isSameYear && isSameMonth && !isSameDay) { + label = `${fDate(startDate, 'DD')} - ${fDate(endDate)}`; + } else if (isSameYear && isSameMonth && isSameDay) { + label = `${fDate(endDate)}`; + } + + return label; +} + +// ---------------------------------------------------------------------- + +/** + * @output 2024-05-28T05:55:31+00:00 + */ +export type DurationProps = { + years?: number; + months?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + milliseconds?: number; +}; + +export function fAdd({ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }: DurationProps) { + const result = dayjs() + .add( + dayjs.duration({ + years, + months, + days, + hours, + minutes, + seconds, + milliseconds, + }) + ) + .format(); + + return result; +} + +/** + * @output 2024-05-28T05:55:31+00:00 + */ +export function fSub({ years = 0, months = 0, days = 0, hours = 0, minutes = 0, seconds = 0, milliseconds = 0 }: DurationProps) { + const result = dayjs() + .subtract( + dayjs.duration({ + years, + months, + days, + hours, + minutes, + seconds, + milliseconds, + }) + ) + .format(); + + return result; +} diff --git a/03_source/cms_backend/src/app/api/invoice/changeStatus/route.ts b/03_source/cms_backend/src/app/api/invoice/changeStatus/route.ts new file mode 100644 index 0000000..b6e9be6 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/changeStatus/route.ts @@ -0,0 +1,98 @@ +// src/app/api/product/saveProduct/route.ts +// +// PURPOSE: +// save product to db by id +// +// RULES: +// T.B.A. + +import type { NextRequest } from 'next/server'; + +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * PUT - change order status + *************************************** */ +export async function PUT(req: NextRequest) { + // logger('[Invoice] list', products.length); + const { searchParams } = req.nextUrl; + const invoiceId = searchParams.get('invoiceId'); + + // RULES: invoiceId must exist + if (!invoiceId) { + return response({ message: 'Invoice ID is required!' }, STATUS.BAD_REQUEST); + } + + const { data } = await req.json(); + + try { + const order = await prisma.invoiceItem.updateMany({ + where: { id: invoiceId }, + data: { status: data.status }, + }); + + return response({ order }, STATUS.OK); + } catch (error) { + console.log({ data }); + return handleError('Invoice - Get list', error); + } +} + +export type IProductItem = { + id: string; + sku: string; + name: string; + code: string; + price: number; + taxes: number; + tags: string[]; + sizes: string[]; + publish: string; + gender: string[]; + coverUrl: string; + images: string[]; + colors: string[]; + quantity: number; + category: string; + available: number; + totalSold: number; + description: string; + totalRatings: number; + totalReviews: number; + // createdAt: IDateValue; + inventoryType: string; + subDescription: string; + priceSale: number | null; + // reviews: IProductReview[]; + newLabel: { + content: string; + enabled: boolean; + }; + saleLabel: { + content: string; + enabled: boolean; + }; + ratings: { + name: string; + starCount: number; + reviewCount: number; + }[]; +}; + +export type IDateValue = string | number | null; + +export type IProductReview = { + id: string; + name: string; + rating: number; + comment: string; + helpful: number; + avatarUrl: string; + postedAt: IDateValue; + isPurchased: boolean; + attachments?: string[]; +}; diff --git a/03_source/cms_backend/src/app/api/invoice/changeStatus/test.http b/03_source/cms_backend/src/app/api/invoice/changeStatus/test.http new file mode 100644 index 0000000..18d57ad --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/changeStatus/test.http @@ -0,0 +1,9 @@ +### + +PUT http://localhost:7272/api/invoice/changeStatus?orderId=1 +content-type: application/json + +{ + "data":{"status": "helloworld"} +} + diff --git a/03_source/cms_backend/src/app/api/invoice/createUser/route.ts b/03_source/cms_backend/src/app/api/invoice/createUser/route.ts new file mode 100644 index 0000000..adeadf7 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/createUser/route.ts @@ -0,0 +1,53 @@ +// src/app/api/user/createUser/route.ts +// +// PURPOSE: +// create user to db +// +// RULES: +// T.B.A. +// + +import type { NextRequest } from 'next/server'; + +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** + *************************************** + * POST - create User + *************************************** + */ +export async function POST(req: NextRequest) { + // logger('[User] list', users.length); + const { data } = await req.json(); + const createForm: CreateUserData = data as unknown as CreateUserData; + + try { + const user = await prisma.userItem.create({ data: createForm }); + return response({ user }, STATUS.OK); + } catch (error) { + return handleError('User - Create', error); + } +} + +type CreateUserData = { + name: string; + city: string; + role: string; + email: string; + state: string; + status: string; + address: string; + country: string; + zipCode: string; + company: string; + avatarUrl: string; + phoneNumber: string; + isVerified: boolean; + // + username: string; + password: string; +}; diff --git a/03_source/cms_backend/src/app/api/invoice/createUser/test.http b/03_source/cms_backend/src/app/api/invoice/createUser/test.http new file mode 100644 index 0000000..41da9b6 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/createUser/test.http @@ -0,0 +1,4 @@ +### + +POST http://localhost:7272/api/user/createUser + diff --git a/03_source/cms_backend/src/app/api/invoice/deleteUser/route.ts b/03_source/cms_backend/src/app/api/invoice/deleteUser/route.ts new file mode 100644 index 0000000..1a684d9 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/deleteUser/route.ts @@ -0,0 +1,47 @@ +// src/app/api/product/deleteUser/route.ts +// +// PURPOSE: +// delete product from db by id +// +// RULES: +// T.B.A. + +import type { NextRequest } from 'next/server'; + +import { logger } from 'src/utils/logger'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * handle Delete Users + *************************************** */ +export async function DELETE(req: NextRequest) { + try { + const { searchParams } = req.nextUrl; + + // RULES: userId must exist + const userId = searchParams.get('userId'); + if (!userId) { + return response({ message: 'User ID is required!' }, STATUS.BAD_REQUEST); + } + + // NOTE: userId confirmed exist, run below + const user = await prisma.userItem.delete({ + // + where: { id: userId }, + }); + + if (!user) { + return response({ message: 'User not found!' }, STATUS.NOT_FOUND); + } + + logger('[User] details', user.id); + + return response({ user }, STATUS.OK); + } catch (error) { + return handleError('User - Get details', error); + } +} diff --git a/03_source/cms_backend/src/app/api/invoice/deleteUser/test.http b/03_source/cms_backend/src/app/api/invoice/deleteUser/test.http new file mode 100644 index 0000000..240b86d --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/deleteUser/test.http @@ -0,0 +1,3 @@ +### + +DELETE http://localhost:7272/api/user/deleteUser?userId=3f431e6f-ad05-4d60-9c25-6a7e92a954ad diff --git a/03_source/cms_backend/src/app/api/invoice/details/route.ts b/03_source/cms_backend/src/app/api/invoice/details/route.ts new file mode 100644 index 0000000..b709d97 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/details/route.ts @@ -0,0 +1,47 @@ +// src/app/api/invoice/details/route.ts +// +// PURPOSE: +// read invoice from db by id +// +// RULES: +// T.B.A. + +import type { NextRequest } from 'next/server'; + +import { logger } from 'src/utils/logger'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * GET Invoice detail + *************************************** */ +export async function GET(req: NextRequest) { + try { + const { searchParams } = req.nextUrl; + + // RULES: invoiceId must exist + const invoiceId = searchParams.get('invoiceId'); + if (!invoiceId) { + return response({ message: 'invoiceId is required!' }, STATUS.BAD_REQUEST); + } + + // NOTE: invoiceId confirmed exist, run below + const invoice = await prisma.invoiceItem.findFirst({ + // include: { reviews: true }, + where: { id: invoiceId.toString() }, + }); + + if (!invoice) { + return response({ message: 'Invoice not found!' }, STATUS.NOT_FOUND); + } + + logger('[Invoice] details', invoice.id); + + return response({ invoice }, STATUS.OK); + } catch (error) { + return handleError('Product - Get details', error); + } +} diff --git a/03_source/cms_backend/src/app/api/invoice/details/test.http b/03_source/cms_backend/src/app/api/invoice/details/test.http new file mode 100644 index 0000000..22df695 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/details/test.http @@ -0,0 +1,4 @@ +### + +GET http://localhost:7272/api/invoice/details?invoiceId=1 + diff --git a/03_source/cms_backend/src/app/api/invoice/image/upload/route.ts b/03_source/cms_backend/src/app/api/invoice/image/upload/route.ts new file mode 100644 index 0000000..22a1330 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/image/upload/route.ts @@ -0,0 +1,30 @@ +// src/app/api/product/image/upload/route.ts +// +// PURPOSE: +// handle upload product image +// +// RULES: +// T.B.A. + +import type { NextRequest } from 'next/server'; + +import { STATUS, response, handleError } from 'src/utils/response'; + +// import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * GET - Products + *************************************** */ +export async function POST(req: NextRequest) { + try { + const { data } = await req.json(); + console.log('helloworld'); + + return response({ hello: 'world' }, STATUS.OK); + } catch (error) { + console.log({ hello: 'world' }); + return handleError('Product - store product image', error); + } +} diff --git a/03_source/cms_backend/src/app/api/invoice/list/route.ts b/03_source/cms_backend/src/app/api/invoice/list/route.ts new file mode 100644 index 0000000..07d00b3 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/list/route.ts @@ -0,0 +1,22 @@ +// src/app/api/invoice/list/route.ts +import { logger } from 'src/utils/logger'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * GET - InvoiceItem + *************************************** */ +export async function GET() { + try { + const invoices = await prisma.invoiceItem.findMany(); + + logger('[Invoice] list', invoices.length); + + return response({ invoices }, STATUS.OK); + } catch (error) { + return handleError('InvoiceItem - Get list', error); + } +} diff --git a/03_source/cms_backend/src/app/api/invoice/list/test.http b/03_source/cms_backend/src/app/api/invoice/list/test.http new file mode 100644 index 0000000..c06a335 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/list/test.http @@ -0,0 +1,3 @@ +### + +GET http://localhost:7272/api/invoice/list diff --git a/03_source/cms_backend/src/app/api/invoice/saveInvoice/route.ts b/03_source/cms_backend/src/app/api/invoice/saveInvoice/route.ts new file mode 100644 index 0000000..fbfe555 --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/saveInvoice/route.ts @@ -0,0 +1,98 @@ +// src/app/api/invoice/saveInvoice/route.ts +// +// PURPOSE: +// save invoice to db by id +// +// RULES: +// T.B.A. + +import type { NextRequest } from 'next/server'; + +import { STATUS, response, handleError } from 'src/utils/response'; + +import prisma from '../../../lib/prisma'; + +// ---------------------------------------------------------------------- + +/** ************************************** + * PUT - Update invoice + *************************************** */ +export async function PUT(req: NextRequest) { + // logger('[Invoice] list', invoices.length); + const { searchParams } = req.nextUrl; + const invoiceId = searchParams.get('invoiceId'); + + // RULES: invoiceId must exist + if (!invoiceId) { + return response({ message: 'Invoice ID is required!' }, STATUS.BAD_REQUEST); + } + + const { data } = await req.json(); + + try { + const invoice = await prisma.invoiceItem.updateMany({ + where: { id: invoiceId }, + data, + }); + + return response({ invoice }, STATUS.OK); + } catch (error) { + console.log({ data }); + return handleError('Invoice - Update invoice', error); + } +} + +export type IInvoiceItem = { + id: string; + sku: string; + name: string; + code: string; + price: number; + taxes: number; + tags: string[]; + sizes: string[]; + publish: string; + gender: string[]; + coverUrl: string; + images: string[]; + colors: string[]; + quantity: number; + category: string; + available: number; + totalSold: number; + description: string; + totalRatings: number; + totalReviews: number; + // createdAt: IDateValue; + inventoryType: string; + subDescription: string; + priceSale: number | null; + // reviews: IInvoiceReview[]; + newLabel: { + content: string; + enabled: boolean; + }; + saleLabel: { + content: string; + enabled: boolean; + }; + ratings: { + name: string; + starCount: number; + reviewCount: number; + }[]; +}; + +export type IDateValue = string | number | null; + +export type IInvoiceReview = { + id: string; + name: string; + rating: number; + comment: string; + helpful: number; + avatarUrl: string; + postedAt: IDateValue; + isPurchased: boolean; + attachments?: string[]; +}; diff --git a/03_source/cms_backend/src/app/api/invoice/saveInvoice/test.http b/03_source/cms_backend/src/app/api/invoice/saveInvoice/test.http new file mode 100644 index 0000000..868141d --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/saveInvoice/test.http @@ -0,0 +1,58 @@ +### + +PUT http://localhost:7272/api/invoice/saveInvoice?invoiceId=1 +content-type: application/json + +{ + "data": { + "id": "1", + "taxes": 10, + "status": "paid", + "discount": 10, + "shipping": 10, + "subtotal": 921.14, + "totalAmount": 993.254, + "items": [ + { + "title": "Urban Explorer Sneakers 1111", + "service": "Technology", + "quantity": 11, + "price": 83.74, + "total": 921.14, + "description": "The sun slowly set over the horizon, painting the sky in vibrant hues of orange and pink." + }, + { + "price": 83.74, + "title": "Urban Explorer Sneakers 22222", + "total": 921.14, + "service": "Technology", + "quantity": 11, + "description": "The sun slowly set over the horizon, painting the sky in vibrant hues of orange and pink." + } + ], + "invoiceNumber": "INV-1991", + "invoiceFrom": { + "id": "e99f09a7-dd88-49d5-b1c8-1daf80c2d7b02", + "name": "Lucian Obrien", + "email": "milo.farrell@hotmail.com", + "company": "Nikolaus - Leuschke", + "primary": false, + "addressType": "Office", + "fullAddress": "1147 Rohan Drive Suite 819 - Burlington, VT / 82021", + "phoneNumber": "+1 416-555-0198" + }, + "invoiceTo": { + "id": "e99f09a7-dd88-49d5-b1c8-1daf80c2d7b03", + "name": "Deja Brady", + "email": "violet.ratke86@yahoo.com", + "company": "Hegmann, Kreiger and Bayer", + "primary": false, + "addressType": "Office", + "fullAddress": "18605 Thompson Circle Apt. 086 - Idaho Falls, WV / 50337", + "phoneNumber": "+44 20 7946 0958" + }, + "sent": 10, + "createdDate": "2025-06-15T17:07:24+08:00", + "dueDate": "2025-06-15T17:07:24+08:00" + } +} diff --git a/03_source/cms_backend/src/app/api/invoice/search/route.ts b/03_source/cms_backend/src/app/api/invoice/search/route.ts new file mode 100644 index 0000000..bf77d2d --- /dev/null +++ b/03_source/cms_backend/src/app/api/invoice/search/route.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from 'next/server'; + +import { logger } from 'src/utils/logger'; +import { STATUS, response, handleError } from 'src/utils/response'; + +import { _products } from 'src/_mock/_product'; + +// ---------------------------------------------------------------------- + +export const runtime = 'edge'; + +/** ************************************** + * GET - Search products + *************************************** */ +export async function GET(req: NextRequest) { + try { + const { searchParams } = req.nextUrl; + const query = searchParams.get('query')?.trim().toLowerCase(); + + if (!query) { + return response({ results: [] }, STATUS.OK); + } + + const products = _products(); + + // Accept search by name or sku + const results = products.filter( + ({ name, sku }) => name.toLowerCase().includes(query) || sku?.toLowerCase().includes(query) + ); + + logger('[Product] search-results', results.length); + + return response({ results }, STATUS.OK); + } catch (error) { + return handleError('Product - Get search', error); + } +} diff --git a/03_source/cms_backend/src/app/api/product/saveProduct/route.ts b/03_source/cms_backend/src/app/api/product/saveProduct/route.ts index fd462e5..6400ae5 100644 --- a/03_source/cms_backend/src/app/api/product/saveProduct/route.ts +++ b/03_source/cms_backend/src/app/api/product/saveProduct/route.ts @@ -66,9 +66,9 @@ export async function POST(req: NextRequest) { where: { id: data.id }, }); - return response({ hello: 'world', data }, STATUS.OK); + return response({ data }, STATUS.OK); } catch (error) { - console.log({ hello: 'world', data }); + console.log({ data }); return handleError('Product - Get list', error); } } diff --git a/03_source/frontend/prettier.config.mjs b/03_source/frontend/prettier.config.mjs index 4a0f615..92057ae 100644 --- a/03_source/frontend/prettier.config.mjs +++ b/03_source/frontend/prettier.config.mjs @@ -11,7 +11,8 @@ const config = { singleQuote: true, trailingComma: 'es5', plugins: [ - // '@ianvs/prettier-plugin-sort-imports' + // + // '@ianvs/prettier-plugin-sort-imports', ], }; diff --git a/03_source/frontend/src/actions/invoice.ts b/03_source/frontend/src/actions/invoice.ts new file mode 100644 index 0000000..d601f92 --- /dev/null +++ b/03_source/frontend/src/actions/invoice.ts @@ -0,0 +1,221 @@ +// src/actions/invoice.ts +import { useMemo } from 'react'; +import axiosInstance, { endpoints, fetcher } from 'src/lib/axios'; +import type { IInvoiceItem } from 'src/types/invoice'; +import type { SWRConfiguration } from 'swr'; +import useSWR from 'swr'; + +// ---------------------------------------------------------------------- + +const swrOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +// ---------------------------------------------------------------------- + +type InvoicesData = { + invoices: IInvoiceItem[]; +}; + +export function useGetInvoices() { + const url = endpoints.invoice.list; + + const { data, isLoading, error, isValidating, mutate } = useSWR( + url, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo( + () => ({ + invoices: data?.invoices || [], + invoicesLoading: isLoading, + invoicesError: error, + invoicesValidating: isValidating, + invoicesEmpty: !isLoading && !isValidating && !data?.invoices.length, + mutate, + }), + [data?.invoices, error, isLoading, isValidating, mutate] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type InvoiceData = { + invoice: IInvoiceItem; +}; + +export function useGetInvoice(invoiceId: string) { + const url = invoiceId ? [endpoints.invoice.details, { params: { invoiceId } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + currentInvoice: data?.invoice, + invoiceLoading: isLoading, + invoiceError: error, + invoiceValidating: isValidating, + }), + [data?.invoice, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SearchResultsData = { + results: IInvoiceItem[]; +}; + +export function useSearchInvoices(query: string) { + const url = query ? [endpoints.invoice.search, { params: { query } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, { + ...swrOptions, + keepPreviousData: true, + }); + + const memoizedValue = useMemo( + () => ({ + searchResults: data?.results || [], + searchLoading: isLoading, + searchError: error, + searchValidating: isValidating, + searchEmpty: !isLoading && !isValidating && !data?.results.length, + }), + [data?.results, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SaveInvoiceData = IInvoiceItem; + +export async function saveInvoice(invoiceId: string, saveInvoiceData: SaveInvoiceData) { + const url = endpoints.invoice.saveInvoice(invoiceId); + const res = await axiosInstance.put(url, { data: saveInvoiceData }); + return res; +} + +export async function uploadInvoiceImage(saveInvoiceData: SaveInvoiceData) { + console.log('save invoice ?'); + // const url = invoiceId ? [endpoints.invoice.details, { params: { invoiceId } }] : ''; + + const res = await axiosInstance.get('http://localhost:7272/api/invoice/helloworld'); + + return res; +} + +// ---------------------------------------------------------------------- + +type CreateInvoiceData = { + // id: string; + sku: string; + name: string; + code: string; + price: number | null; + taxes: number | null; + tags: string[]; + sizes: string[]; + publish: string; + gender: string[]; + coverUrl: string; + images: (string | File)[]; + colors: string[]; + quantity: number | null; + category: string; + available: number; + totalSold: number; + description: string; + totalRatings: number; + totalReviews: number; + inventoryType: string; + subDescription: string; + priceSale: number | null; + newLabel: { + content: string; + enabled: boolean; + }; + saleLabel: { + content: string; + enabled: boolean; + }; + // ratings: { + // name: string; + // starCount: number; + // reviewCount: number; + // }[]; +}; + +export async function createInvoice(createInvoiceData: CreateInvoiceData) { + console.log('create invoice ?'); + // const url = invoiceId ? [endpoints.invoice.details, { params: { invoiceId } }] : ''; + + const res = await axiosInstance.post('http://localhost:7272/api/invoice/createInvoice', { + data: createInvoiceData, + }); + + return res; +} + +// ---------------------------------------------------------------------- + +type DeleteInvoiceResponse = { + success: boolean; + message?: string; +}; + +export async function deleteInvoice(invoiceId: string): Promise { + const url = `http://localhost:7272/api/invoice/deleteInvoice?invoiceId=${invoiceId}`; + + try { + const res = await axiosInstance.delete(url); + console.log({ res }); + + return { + success: true, + message: 'Invoice deleted successfully', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete invoice', + }; + } +} + +// ---------------------------------------------------------------------- + +type ChangeStatusResponse = { + success: boolean; + message?: string; +}; + +export async function changeStatus( + invoiceId: string, + newStatus: string +): Promise { + const url = endpoints.invoice.changeStatus(invoiceId); + + try { + const res = await axiosInstance.put(url, { data: { status: newStatus } }); + + return { + success: true, + message: 'status updated successfully', + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to delete product', + }; + } +} diff --git a/03_source/frontend/src/actions/product.ts b/03_source/frontend/src/actions/product.ts index 7c3c9af..0e4e3a5 100644 --- a/03_source/frontend/src/actions/product.ts +++ b/03_source/frontend/src/actions/product.ts @@ -56,7 +56,7 @@ export function useGetProduct(productId: string) { const memoizedValue = useMemo( () => ({ - product: data?.product, + currentProduct: data?.product, productLoading: isLoading, productError: error, productValidating: isValidating, diff --git a/03_source/frontend/src/locales/langs/hk/common.json b/03_source/frontend/src/locales/langs/hk/common.json index aab6c46..e6e6e6b 100755 --- a/03_source/frontend/src/locales/langs/hk/common.json +++ b/03_source/frontend/src/locales/langs/hk/common.json @@ -109,16 +109,22 @@ "Completed": "已完成", "Cancelled": "已取消", "Refunded": "已退款", - "Date ": "日期", - "Order ": "訂單", - "Customer ": "客戶", - "Items ": "項目", - "Start date ": "開始日期", - "End date ": "結束日期", - "Search customer or order number... ": "搜尋客戶或訂單號碼...", - "Print ": "列印", - "Import ": "匯入", - "Export ": "匯出", + "Date": "日期", + "Customer": "客戶", + "Items": "項目", + "Start date": "開始日期", + "End date": "結束日期", + "Search customer or order number...": "搜尋客戶或訂單號碼...", + "Search customer or invoice number...": "搜尋客戶或訂單號碼...", + "Service": "項目", + "Print": "列印", + "Import": "匯入", + "Due": "過期日", + "Amount": "數目", + "Sent": "發出次數", + "Overdue": "己過期", + "Paid": "已符款", + "Export": "匯出", "Product not found!": "產品未找到!", "Back to list": "返回列表", "hello": "world" diff --git a/03_source/frontend/src/pages/dashboard/invoice/details.tsx b/03_source/frontend/src/pages/dashboard/invoice/details.tsx index a89f017..539b0c4 100644 --- a/03_source/frontend/src/pages/dashboard/invoice/details.tsx +++ b/03_source/frontend/src/pages/dashboard/invoice/details.tsx @@ -1,9 +1,11 @@ +// src/pages/dashboard/invoice/details.tsx import { useParams } from 'src/routes/hooks'; import { CONFIG } from 'src/global-config'; import { _invoices } from 'src/_mock/_invoice'; import { InvoiceDetailsView } from 'src/sections/invoice/view'; +import { useGetInvoice } from 'src/actions/invoice'; // ---------------------------------------------------------------------- @@ -12,13 +14,14 @@ const metadata = { title: `Invoice details | Dashboard - ${CONFIG.appName}` }; export default function Page() { const { id = '' } = useParams(); - const currentInvoice = _invoices.find((invoice) => invoice.id === id); + // const currentInvoice = _invoices.find((invoice) => invoice.id === id); + const { currentInvoice, invoiceLoading, invoiceError } = useGetInvoice(id); return ( <> {metadata.title} - + ); } diff --git a/03_source/frontend/src/pages/dashboard/invoice/edit.tsx b/03_source/frontend/src/pages/dashboard/invoice/edit.tsx index 34110cb..3f707a1 100644 --- a/03_source/frontend/src/pages/dashboard/invoice/edit.tsx +++ b/03_source/frontend/src/pages/dashboard/invoice/edit.tsx @@ -1,7 +1,7 @@ import { useParams } from 'src/routes/hooks'; import { CONFIG } from 'src/global-config'; -import { _invoices } from 'src/_mock/_invoice'; +import { useGetInvoice } from 'src/actions/invoice'; import { InvoiceEditView } from 'src/sections/invoice/view'; @@ -12,7 +12,7 @@ const metadata = { title: `Invoice edit | Dashboard - ${CONFIG.appName}` }; export default function Page() { const { id = '' } = useParams(); - const currentInvoice = _invoices.find((invoice) => invoice.id === id); + const { currentInvoice } = useGetInvoice(id); return ( <> diff --git a/03_source/frontend/src/pages/dashboard/invoice/list.tsx b/03_source/frontend/src/pages/dashboard/invoice/list.tsx index e8addb8..795d901 100644 --- a/03_source/frontend/src/pages/dashboard/invoice/list.tsx +++ b/03_source/frontend/src/pages/dashboard/invoice/list.tsx @@ -1,3 +1,5 @@ +// src/pages/dashboard/invoice/list.tsx + import { CONFIG } from 'src/global-config'; import { InvoiceListView } from 'src/sections/invoice/view'; diff --git a/03_source/frontend/src/pages/dashboard/product/details.tsx b/03_source/frontend/src/pages/dashboard/product/details.tsx index 16aa5b0..b1cbdce 100644 --- a/03_source/frontend/src/pages/dashboard/product/details.tsx +++ b/03_source/frontend/src/pages/dashboard/product/details.tsx @@ -14,7 +14,7 @@ const metadata = { title: `Product details | Dashboard - ${CONFIG.appName}` }; export default function Page() { const { id = '' } = useParams(); - const { product, productLoading, productError } = useGetProduct(id); + const { currentProduct: product, productLoading, productError } = useGetProduct(id); return ( <> diff --git a/03_source/frontend/src/pages/dashboard/product/edit.tsx b/03_source/frontend/src/pages/dashboard/product/edit.tsx index f78e867..5067afd 100644 --- a/03_source/frontend/src/pages/dashboard/product/edit.tsx +++ b/03_source/frontend/src/pages/dashboard/product/edit.tsx @@ -12,13 +12,13 @@ const metadata = { title: `Product edit | Dashboard - ${CONFIG.appName}` }; export default function Page() { const { id = '' } = useParams(); - const { product } = useGetProduct(id); + const { currentProduct } = useGetProduct(id); return ( <> {metadata.title} - + ); } diff --git a/03_source/frontend/src/pages/dashboard/product/list.tsx b/03_source/frontend/src/pages/dashboard/product/list.tsx index 92277d6..2cffc00 100644 --- a/03_source/frontend/src/pages/dashboard/product/list.tsx +++ b/03_source/frontend/src/pages/dashboard/product/list.tsx @@ -1,3 +1,5 @@ +// src/pages/dashboard/product/list.tsx + import { CONFIG } from 'src/global-config'; import { ProductListView } from 'src/sections/product/view'; diff --git a/03_source/frontend/src/pages/product/details.tsx b/03_source/frontend/src/pages/product/details.tsx index f8c04c8..b3d9ce3 100644 --- a/03_source/frontend/src/pages/product/details.tsx +++ b/03_source/frontend/src/pages/product/details.tsx @@ -12,7 +12,7 @@ const metadata = { title: `Product details - ${CONFIG.appName}` }; export default function Page() { const { id = '' } = useParams(); - const { product, productLoading, productError } = useGetProduct(id); + const { currentProduct: product, productLoading, productError } = useGetProduct(id); return ( <> diff --git a/03_source/frontend/src/sections/invoice/invoice-details.tsx b/03_source/frontend/src/sections/invoice/invoice-details.tsx index a3f040b..0db7c03 100644 --- a/03_source/frontend/src/sections/invoice/invoice-details.tsx +++ b/03_source/frontend/src/sections/invoice/invoice-details.tsx @@ -1,4 +1,4 @@ -import type { IInvoice } from 'src/types/invoice'; +import type { IInvoiceItem } from 'src/types/invoice'; import { useState, useCallback } from 'react'; @@ -23,18 +23,32 @@ import { Scrollbar } from 'src/components/scrollbar'; import { InvoiceToolbar } from './invoice-toolbar'; import { InvoiceTotalSummary } from './invoice-total-summary'; +import { useTranslation } from 'react-i18next'; +import { changeStatus } from 'src/actions/invoice'; +import { toast } from 'src/components/snackbar'; // ---------------------------------------------------------------------- type Props = { - invoice?: IInvoice; + invoice: IInvoiceItem; }; export function InvoiceDetails({ invoice }: Props) { + const { t } = useTranslation(); const [currentStatus, setCurrentStatus] = useState(invoice?.status); const handleChangeStatus = useCallback((event: React.ChangeEvent) => { - setCurrentStatus(event.target.value); + // setCurrentStatus(event.target.value); + + try { + changeStatus(invoice.id, event.target.value); + setCurrentStatus(event.target.value); + + toast.success('status changed!'); + } catch (error) { + console.error(error); + toast.warning('error during changing status'); + } }, []); const renderFooter = () => ( @@ -172,7 +186,7 @@ export function InvoiceDetails({ invoice }: Props) { Date create - {fDate(invoice?.createDate)} + {fDate(invoice?.createdDate)} diff --git a/03_source/frontend/src/sections/invoice/invoice-new-edit-form.tsx b/03_source/frontend/src/sections/invoice/invoice-new-edit-form.tsx index 7d7728b..1dcf422 100644 --- a/03_source/frontend/src/sections/invoice/invoice-new-edit-form.tsx +++ b/03_source/frontend/src/sections/invoice/invoice-new-edit-form.tsx @@ -1,16 +1,4 @@ -import type { IInvoice } from 'src/types/invoice'; - -import { z as zod } from 'zod'; -import { useForm } from 'react-hook-form'; -import { useBoolean } from 'minimal-shared/hooks'; -import { zodResolver } from '@hookform/resolvers/zod'; - -import Box from '@mui/material/Box'; -import Card from '@mui/material/Card'; -import Button from '@mui/material/Button'; - -import { paths } from 'src/routes/paths'; -import { useRouter } from 'src/routes/hooks'; +// src/sections/invoice/invoice-new-edit-form.tsx import { today, fIsAfter } from 'src/utils/format-time'; @@ -21,18 +9,37 @@ import { Form, schemaHelper } from 'src/components/hook-form'; import { InvoiceNewEditAddress } from './invoice-new-edit-address'; import { InvoiceNewEditStatusDate } from './invoice-new-edit-status-date'; import { defaultItem, InvoiceNewEditDetails } from './invoice-new-edit-details'; +import { useTranslation } from 'react-i18next'; + +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; + +import { toast } from 'src/components/snackbar'; +import { useRouter } from 'src/routes/hooks'; +import { paths } from 'src/routes/paths'; +import type { IInvoiceItem } from 'src/types/invoice'; +import { fileToBase64 } from 'src/utils/file-to-base64'; +import { z as zod } from 'zod'; +import { saveInvoice } from 'src/actions/invoice'; // ---------------------------------------------------------------------- export type NewInvoiceSchemaType = zod.infer; - export const NewInvoiceSchema = zod .object({ - invoiceTo: schemaHelper.nullableInput(zod.custom(), { - message: 'Invoice to is required!', - }), - createDate: schemaHelper.date({ message: { required: 'Create date is required!' } }), - dueDate: schemaHelper.date({ message: { required: 'Due date is required!' } }), + id: zod.string(), + taxes: zod.number(), + status: zod.string(), + discount: zod.number(), + shipping: zod.number(), + subtotal: zod.number(), + totalAmount: zod.number(), + items: zod.array( zod.object({ title: zod.string().min(1, { message: 'Title is required!' }), @@ -44,17 +51,18 @@ export const NewInvoiceSchema = zod description: zod.string(), }) ), - // Not required - taxes: zod.number(), - status: zod.string(), - discount: zod.number(), - shipping: zod.number(), - subtotal: zod.number(), - totalAmount: zod.number(), invoiceNumber: zod.string(), - invoiceFrom: zod.custom().nullable(), + invoiceFrom: zod.custom().nullable(), + + invoiceTo: schemaHelper.nullableInput(zod.custom(), { + message: 'Invoice to is required!', + }), + sent: zod.number().default(0), + createdDate: schemaHelper.date({ message: { required: 'Create date is required!' } }), + dueDate: schemaHelper.date({ message: { required: 'Due date is required!' } }), + // Not required }) - .refine((data) => !fIsAfter(data.createDate, data.dueDate), { + .refine((data) => !fIsAfter(data.createdDate, data.dueDate), { message: 'Due date cannot be earlier than create date!', path: ['dueDate'], }); @@ -62,18 +70,19 @@ export const NewInvoiceSchema = zod // ---------------------------------------------------------------------- type Props = { - currentInvoice?: IInvoice; + currentInvoice?: IInvoiceItem; }; export function InvoiceNewEditForm({ currentInvoice }: Props) { const router = useRouter(); + const { t } = useTranslation(); const loadingSave = useBoolean(); const loadingSend = useBoolean(); const defaultValues: NewInvoiceSchemaType = { invoiceNumber: 'INV-1990', - createDate: today(), + createdDate: today(), dueDate: null, taxes: 0, shipping: 0, @@ -105,6 +114,7 @@ export function InvoiceNewEditForm({ currentInvoice }: Props) { try { await new Promise((resolve) => setTimeout(resolve, 500)); reset(); + loadingSave.onFalse(); router.push(paths.dashboard.invoice.root); console.info('DATA', JSON.stringify(data, null, 2)); @@ -118,10 +128,15 @@ export function InvoiceNewEditForm({ currentInvoice }: Props) { loadingSend.onTrue(); try { - await new Promise((resolve) => setTimeout(resolve, 500)); - reset(); + if (currentInvoice) { + await saveInvoice(currentInvoice.id, data); + } + loadingSend.onFalse(); + toast.success(currentInvoice ? 'Update success!' : 'Create success!'); + router.push(paths.dashboard.invoice.root); + console.info('DATA', JSON.stringify(data, null, 2)); } catch (error) { console.error(error); @@ -151,6 +166,7 @@ export function InvoiceNewEditForm({ currentInvoice }: Props) { color="inherit" size="large" variant="outlined" + disabled={loadingSave.value && isSubmitting} loading={loadingSave.value && isSubmitting} onClick={handleSaveAsDraft} > @@ -160,6 +176,7 @@ export function InvoiceNewEditForm({ currentInvoice }: Props) { } /> @@ -138,8 +141,8 @@ export function InvoiceTableRow({ menuActions.onClose()}> - Print + {t('Print')} menuActions.onClose()}> - Import + {t('Import')} menuActions.onClose()}> - Export + {t('Export')} @@ -113,7 +123,7 @@ export function InvoiceTableToolbar({ filters, options, dateError, onResetPage } }} > - Service + {t('Service')}