From e82173c6e25cd23ee1678e4b6c6d9da0c6e4926c Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 18 Sep 2025 03:36:05 +0700 Subject: [PATCH] Campaign --- src/services/mutations/campaign.ts | 52 ++ src/services/queries/campaign.ts | 46 ++ src/types/services/campaign.ts | 68 +- .../campaign/AddEditCampaignDrawer.tsx | 580 ++++++++++++------ .../marketing/campaign/CampaignListTable.tsx | 417 ++++++------- .../campaign/DeleteCampaignDialog.tsx | 157 +++++ 6 files changed, 904 insertions(+), 416 deletions(-) create mode 100644 src/services/mutations/campaign.ts create mode 100644 src/services/queries/campaign.ts create mode 100644 src/views/apps/marketing/campaign/DeleteCampaignDialog.tsx diff --git a/src/services/mutations/campaign.ts b/src/services/mutations/campaign.ts new file mode 100644 index 0000000..50f7e9f --- /dev/null +++ b/src/services/mutations/campaign.ts @@ -0,0 +1,52 @@ +import { CampaignRequest } from '@/types/services/campaign' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-toastify' +import { api } from '../api' + +export const useCampaignsMutation = () => { + const queryClient = useQueryClient() + + const createCampaign = useMutation({ + mutationFn: async (newCampaign: CampaignRequest) => { + const response = await api.post('/marketing/campaigns', newCampaign) + return response.data + }, + onSuccess: () => { + toast.success('Campaign created successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Create failed') + } + }) + + const updateCampaign = useMutation({ + mutationFn: async ({ id, payload }: { id: string; payload: CampaignRequest }) => { + const response = await api.put(`/marketing/campaigns/${id}`, payload) + return response.data + }, + onSuccess: () => { + toast.success('Campaign updated successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Update failed') + } + }) + + const deleteCampaign = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/marketing/campaigns/${id}`) + return response.data + }, + onSuccess: () => { + toast.success('Campaign deleted successfully!') + queryClient.invalidateQueries({ queryKey: ['campaigns'] }) + }, + onError: (error: any) => { + toast.error(error.response?.data?.errors?.[0]?.cause || 'Delete failed') + } + }) + + return { createCampaign, updateCampaign, deleteCampaign } +} diff --git a/src/services/queries/campaign.ts b/src/services/queries/campaign.ts new file mode 100644 index 0000000..acd4fcf --- /dev/null +++ b/src/services/queries/campaign.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' +import { api } from '../api' +import { Campaign, Campaigns } from '@/types/services/campaign' + +interface CampaignQueryParams { + page?: number + limit?: number + search?: string +} + +export function useCampaigns(params: CampaignQueryParams = {}) { + const { page = 1, limit = 10, search = '', ...filters } = params + + return useQuery({ + queryKey: ['campaigns', { page, limit, search, ...filters }], + queryFn: async () => { + const queryParams = new URLSearchParams() + + queryParams.append('page', page.toString()) + queryParams.append('limit', limit.toString()) + + if (search) { + queryParams.append('search', search) + } + + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, value.toString()) + } + }) + + const res = await api.get(`/marketing/campaigns?${queryParams.toString()}`) + return res.data.data + } + }) +} + +export function useCampaignById(id: string) { + return useQuery({ + queryKey: ['campaigns', id], + queryFn: async () => { + const res = await api.get(`/marketing/campaigns/${id}`) + return res.data.data + } + }) +} diff --git a/src/types/services/campaign.ts b/src/types/services/campaign.ts index 440cc49..e5657b0 100644 --- a/src/types/services/campaign.ts +++ b/src/types/services/campaign.ts @@ -1,13 +1,63 @@ +export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED' + +export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION' + +export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD' + export interface Campaign { - id: string + id: string // UUID name: string description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean - createdAt: Date - updatedAt: Date + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules?: CampaignRule[] + created_at: string // ISO string + updated_at: string // ISO string +} + +export interface CampaignRule { + id: string // UUID + campaign_id: string // UUID + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string + reward_ref_id?: string // UUID + metadata?: Record + created_at: string // ISO string + updated_at: string // ISO string +} + +export interface CampaignRequest { + name: string + description?: string + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules: CampaignRuleRequest[] +} + +export interface CampaignRuleRequest { + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string +} + +export interface Campaigns { + campaigns: Campaign[] + total: number + page: number + limit: number } diff --git a/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx index 207bffd..477ba94 100644 --- a/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx +++ b/src/views/apps/marketing/campaign/AddEditCampaignDrawer.tsx @@ -22,29 +22,33 @@ import { useForm, Controller, useFieldArray } from 'react-hook-form' import CustomTextField from '@core/components/mui/TextField' // Types -export interface Campaign { - id: string - name: string - description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean - createdAt: Date - updatedAt: Date -} +import { Campaign } from '@/types/services/campaign' +import { useCampaignsMutation } from '@/services/mutations/campaign' + +// Updated Type Definitions +export type CampaignType = 'REWARD' | 'POINTS' | 'TOKENS' | 'MIXED' +export type RuleType = 'TIER' | 'SPEND' | 'PRODUCT' | 'CATEGORY' | 'DAY' | 'LOCATION' +export type RewardType = 'POINTS' | 'TOKENS' | 'REWARD' export interface CampaignRequest { name: string description?: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: Date - endDate: Date - isActive: boolean + type: CampaignType + start_date: string // ISO string + end_date: string // ISO string + is_active: boolean + show_on_app: boolean + position: number + metadata?: Record + rules: CampaignRuleRequest[] +} + +export interface CampaignRuleRequest { + rule_type: RuleType + condition_value?: string + reward_type: RewardType + reward_value?: number + reward_subtype?: string } type Props = { @@ -56,43 +60,42 @@ type Props = { type FormValidateType = { name: string description: string - minimumPurchase: number - rewardType: 'point' | 'voucher' | 'discount' - rewardValue: number - startDate: string - endDate: string - isActive: boolean + type: CampaignType + start_date: string + end_date: string + is_active: boolean + show_on_app: boolean + position: number + // Rules array + rules: { + rule_type: RuleType + condition_value: string + reward_type: RewardType + reward_value: number + reward_subtype: string + }[] } // Initial form data const initialData: FormValidateType = { name: '', description: '', - minimumPurchase: 0, - rewardType: 'point', - rewardValue: 0, - startDate: '', - endDate: '', - isActive: true -} - -// Mock mutation hooks (replace with actual hooks) -const useCampaignMutation = () => { - const createCampaign = { - mutate: (data: CampaignRequest, options?: { onSuccess?: () => void }) => { - console.log('Creating campaign:', data) - setTimeout(() => options?.onSuccess?.(), 1000) + type: 'POINTS', + start_date: '', + end_date: '', + is_active: true, + show_on_app: true, + position: 1, + // Initial rule + rules: [ + { + rule_type: 'SPEND', + condition_value: '', + reward_type: 'POINTS', + reward_value: 0, + reward_subtype: '' } - } - - const updateCampaign = { - mutate: (data: { id: string; payload: CampaignRequest }, options?: { onSuccess?: () => void }) => { - console.log('Updating campaign:', data) - setTimeout(() => options?.onSuccess?.(), 1000) - } - } - - return { createCampaign, updateCampaign } + ] } const AddEditCampaignDrawer = (props: Props) => { @@ -103,7 +106,7 @@ const AddEditCampaignDrawer = (props: Props) => { const [showMore, setShowMore] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) - const { createCampaign, updateCampaign } = useCampaignMutation() + const { createCampaign, updateCampaign } = useCampaignsMutation() // Determine if this is edit mode const isEditMode = Boolean(data?.id) @@ -120,23 +123,43 @@ const AddEditCampaignDrawer = (props: Props) => { defaultValues: initialData }) - const watchedRewardType = watch('rewardType') - const watchedStartDate = watch('startDate') - const watchedEndDate = watch('endDate') + // Field array for rules + const { fields, append, remove } = useFieldArray({ + control, + name: 'rules' + }) + + const watchedStartDate = watch('start_date') + const watchedEndDate = watch('end_date') // Effect to populate form when editing useEffect(() => { if (isEditMode && data) { - // Populate form with existing data const formData: FormValidateType = { name: data.name || '', description: data.description || '', - minimumPurchase: data.minimumPurchase || 0, - rewardType: data.rewardType || 'point', - rewardValue: data.rewardValue || 0, - startDate: data.startDate ? new Date(data.startDate).toISOString().split('T')[0] : '', - endDate: data.endDate ? new Date(data.endDate).toISOString().split('T')[0] : '', - isActive: data.isActive ?? true + type: data.type || 'POINTS', + start_date: data.start_date ? new Date(data.start_date).toISOString().split('T')[0] : '', + end_date: data.end_date ? new Date(data.end_date).toISOString().split('T')[0] : '', + is_active: data.is_active ?? true, + show_on_app: data.show_on_app ?? true, + position: data.position || 1, + // Map existing rules + rules: data.rules?.map(rule => ({ + rule_type: rule.rule_type, + condition_value: rule.condition_value || '', + reward_type: rule.reward_type, + reward_value: rule.reward_value || 0, + reward_subtype: rule.reward_subtype || '' + })) || [ + { + rule_type: 'SPEND', + condition_value: '', + reward_type: 'POINTS', + reward_value: 0, + reward_subtype: '' + } + ] } resetForm(formData) @@ -152,16 +175,34 @@ const AddEditCampaignDrawer = (props: Props) => { try { setIsSubmitting(true) + // Create rules array + const rulesRequest: CampaignRuleRequest[] = formData.rules.map(rule => ({ + rule_type: rule.rule_type, + condition_value: rule.condition_value || undefined, + reward_type: rule.reward_type, + reward_value: rule.reward_value || undefined, + reward_subtype: rule.reward_subtype || undefined + })) + + // Create metadata from rules if needed + const metadata: Record = {} + const spendRule = formData.rules.find(rule => rule.rule_type === 'SPEND') + if (spendRule?.condition_value) { + metadata.minPurchase = parseInt(spendRule.condition_value) + } + // Create CampaignRequest object const campaignRequest: CampaignRequest = { name: formData.name, description: formData.description || undefined, - minimumPurchase: formData.minimumPurchase, - rewardType: formData.rewardType, - rewardValue: formData.rewardValue, - startDate: new Date(formData.startDate), - endDate: new Date(formData.endDate), - isActive: formData.isActive + type: formData.type, + start_date: new Date(formData.start_date).toISOString(), + end_date: new Date(formData.end_date).toISOString(), + is_active: formData.is_active, + show_on_app: formData.show_on_app, + position: formData.position, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + rules: rulesRequest } if (isEditMode && data?.id) { @@ -206,52 +247,59 @@ const AddEditCampaignDrawer = (props: Props) => { }).format(value) } - const getRewardTypeLabel = (type: 'point' | 'voucher' | 'discount') => { + const getRewardTypeLabel = (type: RewardType) => { switch (type) { - case 'point': + case 'POINTS': return 'Poin' - case 'voucher': - return 'Voucher' - case 'discount': - return 'Diskon' + case 'TOKENS': + return 'Token' + case 'REWARD': + return 'Reward' default: return type } } - const getRewardValuePlaceholder = (type: 'point' | 'voucher' | 'discount') => { + const getRewardValuePlaceholder = (type: RewardType) => { switch (type) { - case 'point': + case 'POINTS': return 'Jumlah poin yang diberikan' - case 'voucher': - return 'Nilai voucher dalam Rupiah' - case 'discount': - return 'Persentase diskon (1-100)' + case 'TOKENS': + return 'Jumlah token yang diberikan' + case 'REWARD': + return 'Nilai reward' default: return 'Nilai reward' } } - const getRewardValueRules = (type: 'point' | 'voucher' | 'discount') => { - const baseRules = { - required: 'Nilai reward wajib diisi', - min: { - value: 1, - message: 'Nilai reward minimal 1' - } + const getConditionValuePlaceholder = (ruleType: RuleType) => { + switch (ruleType) { + case 'SPEND': + return 'Minimum pembelian (Rupiah)' + case 'TIER': + return 'Tier pelanggan (misal: GOLD, SILVER)' + case 'PRODUCT': + return 'ID atau nama produk' + case 'CATEGORY': + return 'Kategori produk' + case 'DAY': + return 'Hari dalam seminggu (misal: MONDAY)' + case 'LOCATION': + return 'Lokasi atau kota' + default: + return 'Nilai kondisi' } + } - if (type === 'discount') { + const getConditionValueInputProps = (ruleType: RuleType) => { + if (ruleType === 'SPEND') { return { - ...baseRules, - max: { - value: 100, - message: 'Persentase diskon maksimal 100%' - } + startAdornment: Rp, + type: 'number' as const } } - - return baseRules + return { type: 'text' as const } } return ( @@ -314,71 +362,39 @@ const AddEditCampaignDrawer = (props: Props) => { /> - {/* Minimum Purchase */} + {/* Jenis Kampanye */}
- Minimum Pembelian * + Jenis Kampanye * ( - 0 ? formatCurrency(field.value) : '')} - InputProps={{ - startAdornment: Rp - }} - onChange={e => field.onChange(Number(e.target.value))} - /> - )} - /> -
- - {/* Jenis Reward */} -
- - Jenis Reward * - - ( - - + +
- Poin + Points
- +
- Voucher + Tokens
- +
- - Diskon + + Reward +
+
+ +
+ + Mixed
@@ -386,39 +402,200 @@ const AddEditCampaignDrawer = (props: Props) => { />
- {/* Nilai Reward */} + {/* Rules Section */}
- - Nilai {getRewardTypeLabel(watchedRewardType)} * - - ( - Rp - ) : undefined, - endAdornment: - watchedRewardType === 'discount' ? ( - % - ) : watchedRewardType === 'point' ? ( - Poin - ) : undefined - }} - onChange={e => field.onChange(Number(e.target.value))} - /> - )} - /> + + Aturan Kampanye + + + + {fields.map((field, index) => ( + + + + Aturan {index + 1} + + {fields.length > 1 && ( + remove(index)}> + + + )} + + +
+ {/* Rule Type */} +
+ + Tipe Aturan * + + ( + + Minimum Pembelian + Tier Pelanggan + Produk Tertentu + Kategori Produk + Hari Tertentu + Lokasi Tertentu + + )} + /> +
+ + {/* Condition Value */} +
+ + Nilai Kondisi * + + { + const ruleType = watch(`rules.${index}.rule_type`) + return ( + + ) + }} + /> +
+ + {/* Reward Type */} +
+ + Jenis Reward * + + ( + + +
+ + Points +
+
+ +
+ + Tokens +
+
+ +
+ + Reward +
+
+
+ )} + /> +
+ + {/* Reward Value */} +
+ + Nilai Reward * + + { + const rewardType = watch(`rules.${index}.reward_type`) + return ( + Poin + ) : rewardType === 'TOKENS' ? ( + Token + ) : undefined + }} + onChange={e => field.onChange(Number(e.target.value))} + /> + ) + }} + /> +
+ + {/* Reward Subtype (jika reward type adalah REWARD) */} + {watch(`rules.${index}.reward_type`) === 'REWARD' && ( +
+ + Sub-tipe Reward + + ( + + Diskon Persentase + Diskon Nominal + Cashback + Gratis Ongkir + + )} + /> +
+ )} +
+
+ ))}
{/* Tanggal Mulai */} @@ -427,7 +604,7 @@ const AddEditCampaignDrawer = (props: Props) => { Tanggal Mulai * ( @@ -435,8 +612,8 @@ const AddEditCampaignDrawer = (props: Props) => { {...field} fullWidth type='date' - error={!!errors.startDate} - helperText={errors.startDate?.message} + error={!!errors.start_date} + helperText={errors.start_date?.message} InputLabelProps={{ shrink: true }} @@ -451,7 +628,7 @@ const AddEditCampaignDrawer = (props: Props) => { Tanggal Berakhir * { {...field} fullWidth type='date' - error={!!errors.endDate} - helperText={errors.endDate?.message} + error={!!errors.end_date} + helperText={errors.end_date?.message} InputLabelProps={{ shrink: true }} @@ -485,7 +662,7 @@ const AddEditCampaignDrawer = (props: Props) => { {/* Status Aktif */}
( { />
+ {/* Show on App */} +
+ ( + } + label='Tampilkan di Aplikasi' + /> + )} + /> +
+ {/* Tampilkan selengkapnya */} {!showMore && ( + + + + ) +} + +export default DeleteCampaignDialog