实现价格折扣动态调整
This commit is contained in:
@@ -19,3 +19,49 @@ export async function getPageProductSku(params: {
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createProductSku(data: {
|
||||
product_id: number
|
||||
code: string
|
||||
name: string
|
||||
price: string
|
||||
discount_id?: number
|
||||
}) {
|
||||
return callByUser<ProductSku>("/api/admin/product/sku/create", {
|
||||
product_id: data.product_id,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
price: data.price,
|
||||
discount_id: data.discount_id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProductSku(data: {
|
||||
id: number
|
||||
code?: string
|
||||
name?: string
|
||||
price?: string
|
||||
discount_id?: number | null
|
||||
}) {
|
||||
return callByUser<ProductSku>("/api/admin/product/sku/update", {
|
||||
id: data.id,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
price: data.price,
|
||||
discount_id: data.discount_id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteProductSku(id: number) {
|
||||
return callByUser<ProductSku>("/api/admin/product/sku/remove", { id })
|
||||
}
|
||||
|
||||
export async function batchUpdateProductSkuDiscount(data: {
|
||||
product_id: number
|
||||
discount_id: number | null
|
||||
}) {
|
||||
return callByUser<void>("/api/admin/product/sku/update/discount/batch", {
|
||||
product_id: data.product_id,
|
||||
discount_id: data.discount_id,
|
||||
})
|
||||
}
|
||||
|
||||
47
src/actions/product_discount.ts
Normal file
47
src/actions/product_discount.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
"use server"
|
||||
|
||||
import type { PageRecord } from "@/lib/api"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
export async function getAllProductDiscount() {
|
||||
return callByUser<ProductDiscount[]>("/api/admin/product/discount/all")
|
||||
}
|
||||
|
||||
export async function getPageProductDiscount(params: {
|
||||
page: number
|
||||
size: number
|
||||
}) {
|
||||
return callByUser<PageRecord<ProductDiscount>>(
|
||||
"/api/admin/product/discount/page",
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export async function createProductDiscount(data: {
|
||||
name: string
|
||||
discount: string
|
||||
}) {
|
||||
return callByUser<ProductDiscount>("/api/admin/product/discount/create", {
|
||||
name: data.name,
|
||||
discount: Number(data.discount),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProductDiscount(data: {
|
||||
id: number
|
||||
name?: string
|
||||
discount?: string
|
||||
}) {
|
||||
return callByUser<ProductDiscount>("/api/admin/product/discount/update", {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
discount: data.discount ? Number(data.discount) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteProductDiscount(id: number) {
|
||||
return callByUser<ProductDiscount>("/api/admin/product/discount/remove", {
|
||||
id,
|
||||
})
|
||||
}
|
||||
122
src/app/(root)/discount/create.tsx
Normal file
122
src/app/(root)/discount/create.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { createProductDiscount } from "@/actions/product_discount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "请输入折扣名称"),
|
||||
discount: z.string().min(1, "请输入折扣代码"),
|
||||
})
|
||||
|
||||
export function CreateDiscount(props: { onSuccess?: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
discount: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
const resp = await createProductDiscount(data)
|
||||
if (resp.success) {
|
||||
form.reset()
|
||||
toast.success("折扣创建成功")
|
||||
props.onSuccess?.()
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>创建折扣</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建折扣</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="discount-create" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="discount-create-name">名称</FieldLabel>
|
||||
<Input
|
||||
id="discount-create-name"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="discount"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="discount-create-discount">
|
||||
代码
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="discount-create-discount"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="discount-create">
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
131
src/app/(root)/discount/page.tsx
Normal file
131
src/app/(root)/discount/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
import { format } from "date-fns"
|
||||
import { Suspense, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
deleteProductDiscount,
|
||||
getPageProductDiscount,
|
||||
} from "@/actions/product_discount"
|
||||
import { DataTable, useDataTable } from "@/components/data-table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
import { CreateDiscount } from "./create"
|
||||
import { UpdateDiscount } from "./update"
|
||||
|
||||
export default function DiscountPage() {
|
||||
const table = useDataTable((page, size) =>
|
||||
getPageProductDiscount({ page, size }),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 操作栏 */}
|
||||
<div className="flex justify-between items-stretch">
|
||||
<div className="flex gap-3">
|
||||
<CreateDiscount onSuccess={table.refresh} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据表 */}
|
||||
<Suspense>
|
||||
<DataTable<ProductDiscount>
|
||||
{...table}
|
||||
columns={[
|
||||
{ header: "名称", accessorKey: "name" },
|
||||
{ header: "折扣", accessorKey: "discount" },
|
||||
{
|
||||
header: "创建时间",
|
||||
accessorKey: "created_at",
|
||||
cell: ({ row }) =>
|
||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
||||
},
|
||||
{
|
||||
header: "更新时间",
|
||||
accessorKey: "updated_at",
|
||||
cell: ({ row }) =>
|
||||
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
|
||||
},
|
||||
{
|
||||
header: "操作",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2">
|
||||
<UpdateDiscount
|
||||
discount={row.original}
|
||||
onSuccess={table.refresh}
|
||||
/>
|
||||
<DeleteButton
|
||||
discount={row.original}
|
||||
onSuccess={table.refresh}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
discount,
|
||||
onSuccess,
|
||||
}: {
|
||||
discount: ProductDiscount
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await deleteProductDiscount(discount.id)
|
||||
if (resp.success) {
|
||||
toast.success("删除成功")
|
||||
onSuccess?.()
|
||||
} else {
|
||||
toast.error(resp.message ?? "删除失败")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive" disabled={loading}>
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除折扣「{discount.name}」吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
140
src/app/(root)/discount/update.tsx
Normal file
140
src/app/(root)/discount/update.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { updateProductDiscount } from "@/actions/product_discount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "请输入折扣名称"),
|
||||
discount: z.string().min(1, "请输入折扣代码"),
|
||||
})
|
||||
|
||||
export function UpdateDiscount(props: {
|
||||
discount: ProductDiscount
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: props.discount.name,
|
||||
discount: String(props.discount.discount),
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
const resp = await updateProductDiscount({
|
||||
id: props.discount.id,
|
||||
...data,
|
||||
})
|
||||
if (resp.success) {
|
||||
toast.success("折扣修改成功")
|
||||
props.onSuccess?.()
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset({
|
||||
name: props.discount.name,
|
||||
discount: String(props.discount.discount),
|
||||
})
|
||||
}
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
修改
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改折扣</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="discount-update" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="discount-update-name">名称</FieldLabel>
|
||||
<Input
|
||||
id="discount-update-name"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="discount"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="discount-update-discount">
|
||||
代码
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="discount-update-discount"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="discount-update">
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
Package,
|
||||
Shield,
|
||||
ShoppingBag,
|
||||
SquarePercent,
|
||||
SquarePercentIcon,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
@@ -194,6 +196,11 @@ export default function Navigation() {
|
||||
{/* 运营 */}
|
||||
<NavGroup title="运营">
|
||||
<NavItem href="/product" icon={ShoppingBag} label="产品管理" />
|
||||
<NavItem
|
||||
href="/discount"
|
||||
icon={SquarePercent}
|
||||
label="折扣管理"
|
||||
/>
|
||||
<NavItem href="/resources" icon={Package} label="套餐管理" />
|
||||
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
|
||||
<NavItem href="/channel" icon={Code} label="IP管理" />
|
||||
|
||||
144
src/app/(root)/product/batch-discount.tsx
Normal file
144
src/app/(root)/product/batch-discount.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { batchUpdateProductSkuDiscount } from "@/actions/product"
|
||||
import { getAllProductDiscount } from "@/actions/product_discount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
|
||||
const schema = z.object({
|
||||
discount_id: z.string().min(1, "请选择折扣"),
|
||||
})
|
||||
|
||||
export function BatchUpdateDiscount(props: {
|
||||
productId: number
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
discount_id: "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getAllProductDiscount().then(resp => {
|
||||
if (resp.success) {
|
||||
setDiscounts(resp.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
const resp = await batchUpdateProductSkuDiscount({
|
||||
product_id: props.productId,
|
||||
discount_id:
|
||||
data.discount_id === "none" ? null : Number(data.discount_id),
|
||||
})
|
||||
if (resp.success) {
|
||||
toast.success("批量配置折扣成功")
|
||||
props.onSuccess?.()
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(resp.message ?? "操作失败")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset()
|
||||
}
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" disabled={!props.productId}>
|
||||
批量配置折扣
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量配置折扣</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="sku-batch-discount" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="discount_id"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel>折扣</FieldLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue placeholder="请选择要应用的折扣" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{discounts.map(d => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.name}({d.discount}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="sku-batch-discount">
|
||||
确认应用
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
220
src/app/(root)/product/create.tsx
Normal file
220
src/app/(root)/product/create.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { createProductSku } from "@/actions/product"
|
||||
import { getAllProductDiscount } from "@/actions/product_discount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().min(1, "请输入套餐编码"),
|
||||
name: z.string().min(1, "请输入套餐名称"),
|
||||
price: z
|
||||
.string()
|
||||
.min(1, "请输入单价")
|
||||
.refine(
|
||||
v => !Number.isNaN(Number(v)) && Number(v) > 0,
|
||||
"请输入有效的正数单价",
|
||||
),
|
||||
discount_id: z.string().optional(),
|
||||
})
|
||||
|
||||
export function CreateProductSku(props: {
|
||||
productId: number
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
name: "",
|
||||
price: "",
|
||||
discount_id: "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getAllProductDiscount()
|
||||
.then(resp => {
|
||||
if (resp.success) {
|
||||
setDiscounts(resp.data)
|
||||
}
|
||||
})
|
||||
.catch(e => toast.error(e.message))
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
const resp = await createProductSku({
|
||||
product_id: props.productId,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
price: data.price,
|
||||
discount_id:
|
||||
data.discount_id && data.discount_id !== ""
|
||||
? Number(data.discount_id)
|
||||
: undefined,
|
||||
})
|
||||
if (resp.success) {
|
||||
form.reset()
|
||||
toast.success("套餐创建成功")
|
||||
props.onSuccess?.()
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (!value) {
|
||||
form.reset()
|
||||
}
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={!props.productId}>新建套餐</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建套餐</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="sku-create" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-create-code">套餐编码</FieldLabel>
|
||||
<Input
|
||||
id="sku-create-code"
|
||||
placeholder="请输入套餐编码"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-create-name">套餐名称</FieldLabel>
|
||||
<Input
|
||||
id="sku-create-name"
|
||||
placeholder="请输入套餐名称"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-create-price">单价</FieldLabel>
|
||||
<Input
|
||||
id="sku-create-price"
|
||||
placeholder="请输入单价"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="discount_id"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel>折扣</FieldLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue placeholder="选择折扣" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{discounts.map(d => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.name}({d.discount}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="sku-create">
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { format } from "date-fns"
|
||||
import { EyeIcon, EyeOffIcon, PlusIcon, TrashIcon } from "lucide-react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { getAllProduct, getPageProductSku } from "@/actions/product"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
deleteProductSku,
|
||||
getAllProduct,
|
||||
getPageProductSku,
|
||||
} from "@/actions/product"
|
||||
import { DataTable, useDataTable } from "@/components/data-table"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { Product } from "@/models/product"
|
||||
import type { ProductSku } from "@/models/product_sku"
|
||||
import { BatchUpdateDiscount } from "./batch-discount"
|
||||
import { CreateProductSku } from "./create"
|
||||
import { UpdateProductSku } from "./update"
|
||||
|
||||
export default function ProductPage() {
|
||||
const [selected, setSelected] = useState<number | undefined>(undefined)
|
||||
@@ -36,7 +50,6 @@ function Products(props: {
|
||||
const resp = await getAllProduct()
|
||||
if (resp.success) {
|
||||
setList(resp.data)
|
||||
console.log(resp.data)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -52,46 +65,10 @@ function Products(props: {
|
||||
<section className="flex-none basis-64 bg-background rounded-lg">
|
||||
<header className="pl-3 pr-1 h-10 border-b flex items-center justify-between">
|
||||
<h3 className="text-sm">产品列表</h3>
|
||||
<div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>新建产品</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{!!selected && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
{selected.status ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{selected.status ? "禁用产品" : "启用产品"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>删除产品</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</header>
|
||||
<ul>
|
||||
<ul className="flex flex-col gap-1 py-1">
|
||||
{list.map(item => (
|
||||
<li key={item.id} className="p-1">
|
||||
<li key={item.id} className="px-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
@@ -125,11 +102,18 @@ function ProductSkus(props: { selected?: number }) {
|
||||
|
||||
return (
|
||||
<div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3">
|
||||
<div>
|
||||
<Button>新建套餐</Button>
|
||||
<div className="flex gap-3">
|
||||
<CreateProductSku
|
||||
productId={props.selected ?? 0}
|
||||
onSuccess={table.refresh}
|
||||
/>
|
||||
<BatchUpdateDiscount
|
||||
productId={props.selected ?? 0}
|
||||
onSuccess={table.refresh}
|
||||
/>
|
||||
</div>
|
||||
<Suspense>
|
||||
<DataTable
|
||||
<DataTable<ProductSku>
|
||||
classNames={{
|
||||
root: "overflow-auto",
|
||||
}}
|
||||
@@ -138,10 +122,13 @@ function ProductSkus(props: { selected?: number }) {
|
||||
{ header: "套餐编码", accessorKey: "code" },
|
||||
{ header: "套餐名称", accessorKey: "name" },
|
||||
{ header: "单价", accessorKey: "price" },
|
||||
{ header: "折扣", accessorKey: "discount" },
|
||||
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" },
|
||||
{
|
||||
header: "最终价格",
|
||||
accessorFn: row => Number(row.price) * row.discount,
|
||||
accessorFn: row =>
|
||||
row.discount
|
||||
? (Number(row.price) * Number(row.discount.discount)) / 100
|
||||
: Number(row.price),
|
||||
},
|
||||
{
|
||||
header: "创建时间",
|
||||
@@ -151,10 +138,67 @@ function ProductSkus(props: { selected?: number }) {
|
||||
header: "更新时间",
|
||||
accessorFn: row => format(row.updated_at, "yyyy-MM-dd HH:mm"),
|
||||
},
|
||||
{ header: "操作", cell: () => <div></div> },
|
||||
{
|
||||
header: "操作",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1">
|
||||
<UpdateProductSku
|
||||
sku={row.original}
|
||||
onSuccess={table.refresh}
|
||||
/>
|
||||
<DeleteButton sku={row.original} onSuccess={table.refresh} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton(props: { sku: ProductSku; onSuccess?: () => void }) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const resp = await deleteProductSku(props.sku.id)
|
||||
if (resp.success) {
|
||||
toast.success("删除成功")
|
||||
props.onSuccess?.()
|
||||
} else {
|
||||
toast.error(resp.message ?? "删除失败")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive" disabled={loading}>
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除套餐「{props.sku.name}」吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
225
src/app/(root)/product/update.tsx
Normal file
225
src/app/(root)/product/update.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import z from "zod"
|
||||
import { updateProductSku } from "@/actions/product"
|
||||
import { getAllProductDiscount } from "@/actions/product_discount"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { ProductDiscount } from "@/models/product_discount"
|
||||
import type { ProductSku } from "@/models/product_sku"
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().min(1, "请输入套餐编码"),
|
||||
name: z.string().min(1, "请输入套餐名称"),
|
||||
price: z
|
||||
.string()
|
||||
.min(1, "请输入单价")
|
||||
.refine(
|
||||
v => !Number.isNaN(Number(v)) && Number(v) > 0,
|
||||
"请输入有效的正数单价",
|
||||
),
|
||||
discount_id: z.string().optional(),
|
||||
})
|
||||
|
||||
export function UpdateProductSku(props: {
|
||||
sku: ProductSku
|
||||
onSuccess?: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [discounts, setDiscounts] = useState<ProductDiscount[]>([])
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
code: props.sku.code,
|
||||
name: props.sku.name,
|
||||
price: props.sku.price,
|
||||
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getAllProductDiscount().then(resp => {
|
||||
if (resp.success) {
|
||||
setDiscounts(resp.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
try {
|
||||
const resp = await updateProductSku({
|
||||
id: props.sku.id,
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
price: data.price,
|
||||
discount_id:
|
||||
data.discount_id && data.discount_id !== ""
|
||||
? Number(data.discount_id)
|
||||
: null,
|
||||
})
|
||||
if (resp.success) {
|
||||
toast.success("套餐修改成功")
|
||||
props.onSuccess?.()
|
||||
setOpen(false)
|
||||
} else {
|
||||
toast.error(resp.message)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : error
|
||||
toast.error(`接口请求错误: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
if (value) {
|
||||
form.reset({
|
||||
code: props.sku.code,
|
||||
name: props.sku.name,
|
||||
price: props.sku.price,
|
||||
discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
|
||||
})
|
||||
}
|
||||
setOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
修改
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改套餐</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="sku-update" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-update-code">套餐编码</FieldLabel>
|
||||
<Input
|
||||
id="sku-update-code"
|
||||
placeholder="请输入套餐编码"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-update-name">套餐名称</FieldLabel>
|
||||
<Input
|
||||
id="sku-update-name"
|
||||
placeholder="请输入套餐名称"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="sku-update-price">单价</FieldLabel>
|
||||
<Input
|
||||
id="sku-update-price"
|
||||
placeholder="请输入单价"
|
||||
{...field}
|
||||
aria-invalid={fieldState.invalid}
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="discount_id"
|
||||
render={({ field, fieldState }) => (
|
||||
<Field>
|
||||
<FieldLabel>折扣</FieldLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
aria-invalid={fieldState.invalid}
|
||||
>
|
||||
<SelectValue placeholder="选择折扣" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{discounts.map(d => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>
|
||||
{d.name}({d.discount}%)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost">取消</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" form="sku-update">
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
@@ -33,7 +33,7 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
|
||||
6
src/models/product_discount.ts
Normal file
6
src/models/product_discount.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Model } from "./base/model"
|
||||
|
||||
export type ProductDiscount = Model & {
|
||||
name: string
|
||||
discount: number
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Model } from "./base/model"
|
||||
import type { Product } from "./product"
|
||||
import type { ProductDiscount } from "./product_discount"
|
||||
|
||||
export type ProductSku = Model & {
|
||||
product_id: number
|
||||
code: string
|
||||
name: string
|
||||
price: string
|
||||
discount: number
|
||||
|
||||
product?: Product
|
||||
discount?: ProductDiscount
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user