+ {/* Step indicator */}
+
+ {STEPS.map(({ number, label }, index) => (
+
+
number
+ ? "bg-accent text-foreground"
+ : "text-text-muted"
+ )}
+ >
+ number
+ ? "bg-foreground/10"
+ : "bg-border"
+ )}
+ >
+ {number}
+
+ {label}
+
+ {index < STEPS.length - 1 && (
+
number ? "bg-primary" : "bg-border"
+ )}
+ />
+ )}
+
+ ))}
+
+
+ {/* Step content */}
+
+ {step === 1 && }
+ {step === 2 && (
+
+ )}
+ {step === 3 && }
+
+
+ {/* Error */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Navigation */}
+
+
+
+ {step < totalSteps ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/create/step-campaign-basics.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-campaign-basics.tsx
new file mode 100644
index 0000000..9b59654
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-campaign-basics.tsx
@@ -0,0 +1,123 @@
+"use client";
+
+import type { UseFormReturn } from "react-hook-form";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@tokenization/ui/form";
+import { Input } from "@tokenization/ui/input";
+import { Textarea } from "@tokenization/ui/textarea";
+import type { CreateCampaignFormValues } from "@/features/campaigns/types/campaign.types";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
+
+interface Props {
+ form: UseFormReturn
;
+}
+
+export function StepCampaignBasics({ form }: Props) {
+ return (
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/create/step-create-token.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-create-token.tsx
new file mode 100644
index 0000000..e76326b
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-create-token.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import type { UseFormReturn } from "react-hook-form";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@tokenization/ui/form";
+import { Input } from "@tokenization/ui/input";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@tokenization/ui/select";
+import type { CreateCampaignFormValues } from "@/features/campaigns/types/campaign.types";
+
+interface Props {
+ form: UseFormReturn;
+}
+
+export function StepCreateToken({ form }: Props) {
+ const tokenAsset = form.watch("tokenAsset");
+
+ return (
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/create/step-escrow-config.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-escrow-config.tsx
new file mode 100644
index 0000000..7b2db03
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/create/step-escrow-config.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import type { UseFormReturn } from "react-hook-form";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@tokenization/ui/form";
+import { Input } from "@tokenization/ui/input";
+import type { CreateCampaignFormValues } from "@/features/campaigns/types/campaign.types";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
+import { formatCurrency } from "@/lib/utils";
+
+interface Props {
+ form: UseFormReturn;
+ totalCommitment: number;
+}
+
+export function StepEscrowConfig({ form, totalCommitment }: Props) {
+ const targetAmount = form.watch("targetAmount");
+ const durationDays = form.watch("durationDays");
+
+ return (
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx
new file mode 100644
index 0000000..fa5a88b
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/loans/manage-loans-view.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@tokenization/ui/form";
+import { Input } from "@tokenization/ui/input";
+import { Textarea } from "@tokenization/ui/textarea";
+import { Button } from "@tokenization/ui/button";
+import { CheckCircle2, Pencil, Wallet, X, Check } from "lucide-react";
+import { useManageLoans } from "@/features/campaigns/hooks/use-manage-loans";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
+import { formatCurrency } from "@/lib/utils";
+
+export function ManageLoansView() {
+ const {
+ form,
+ milestones,
+ walletAddress,
+ editingId,
+ editValues,
+ setEditValues,
+ completeMilestone,
+ startEdit,
+ cancelEdit,
+ saveEdit,
+ onSubmit,
+ } = useManageLoans();
+
+ const shortAddress = walletAddress
+ ? `${walletAddress.slice(0, 8)}…${walletAddress.slice(-6)}`
+ : null;
+
+ return (
+
+ {/* Milestones list */}
+
+
+ Beneficiarios
+
+
+ {milestones.length === 0 ? (
+
No hay hitos registrados.
+ ) : (
+ milestones.map((milestone) => {
+ const isCompleted = milestone.status === "completed";
+ const isEditing = editingId === milestone.id;
+
+ if (isEditing) {
+ return (
+
+
+
+
+ setEditValues((v) => ({ ...v, description: e.target.value }))
+ }
+ autoFocus
+ />
+
+
+
+
+
+ setEditValues((prev) => ({ ...prev, amount: parseNumericInput(e.target.value) }))}
+ />
+
+ USDC
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {milestone.description}
+
+
+ USDC {formatCurrency(milestone.amount)}
+
+
+
+ {isCompleted ? (
+
+
+ Completado
+
+ ) : (
+
+
+
+
+ )}
+
+ );
+ })
+ )}
+
+
+ {/* Add new milestone */}
+
+
+ Agregar Nuevo Beneficiario
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx
new file mode 100644
index 0000000..7dfa8c4
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/add-funds-dialog.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+} from "@tokenization/ui/dialog";
+import { Button } from "@tokenization/ui/button";
+import { Progress } from "@tokenization/ui/progress";
+import { Landmark, Zap } from "lucide-react";
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+import { mapCampaignProgress } from "@/features/campaigns/utils/campaign.mapper";
+
+interface AddFundsDialogProps {
+ campaign: Campaign | null;
+ onClose: () => void;
+ onFundNow: () => void;
+}
+
+export function AddFundsDialog({
+ campaign,
+ onClose,
+ onFundNow,
+}: AddFundsDialogProps) {
+ const progress = campaign ? mapCampaignProgress(campaign) : 0;
+
+ return (
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx
new file mode 100644
index 0000000..a0f16cb
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/create-roi-dialog.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import type { UseFormReturn } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@tokenization/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@tokenization/ui/form";
+import { Input } from "@tokenization/ui/input";
+import { Button } from "@tokenization/ui/button";
+import { ArrowRight, Info } from "lucide-react";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
+import type { RoiFormValues } from "@/features/campaigns/hooks/use-roi";
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+
+interface CreateRoiDialogProps {
+ campaign: Campaign | null;
+ form: UseFormReturn;
+ onClose: () => void;
+ onSubmit: (e?: React.BaseSyntheticEvent) => Promise;
+}
+
+export function CreateRoiDialog({
+ campaign,
+ form,
+ onClose,
+ onSubmit,
+}: CreateRoiDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table.tsx
new file mode 100644
index 0000000..e9fe378
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-table.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import Link from "next/link";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@tokenization/ui/table";
+import { Badge } from "@tokenization/ui/badge";
+import { Button } from "@tokenization/ui/button";
+import { Progress } from "@tokenization/ui/progress";
+import { cn } from "@tokenization/shared/lib/utils";
+import { ArrowUpCircle, Landmark, TrendingUp } from "lucide-react";
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+import { CAMPAIGN_STATUS_CONFIG } from "@/features/campaigns/constants/campaign-status";
+import { mapCampaignProgress } from "@/features/campaigns/utils/campaign.mapper";
+import { formatCurrency } from "@/lib/utils";
+
+interface RoiTableProps {
+ campaigns: Campaign[];
+ onCreateRoi: (campaign: Campaign) => void;
+ onAddFunds: (campaign: Campaign) => void;
+}
+
+export function RoiTable({ campaigns, onCreateRoi, onAddFunds }: RoiTableProps) {
+ return (
+
+
+
+
+
+ Nombre del Proyecto
+
+
+ Progreso de Préstamos
+
+
+ Invertido
+
+
+ Estado
+
+
+ Acciones
+
+
+
+
+
+ {campaigns.map((campaign) => {
+ const progress = mapCampaignProgress(campaign);
+ const statusCfg = CAMPAIGN_STATUS_CONFIG[campaign.status];
+
+ return (
+
+ {/* Name */}
+
+
+
+ #{campaign.id.slice(0, 3).toUpperCase()} {campaign.title}
+
+
+ {campaign.description}
+
+
+
+
+ {/* Progress */}
+
+
+
+
+ {/* Invested */}
+
+
+ ${formatCurrency(campaign.raisedAmount)}
+
+
+
+ {/* Status */}
+
+
+ {statusCfg.label}
+
+
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-view.tsx b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-view.tsx
new file mode 100644
index 0000000..4f7ad6f
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/components/roi/roi-view.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+import Link from "next/link";
+import { Button } from "@tokenization/ui/button";
+import { Plus } from "lucide-react";
+import { StatItem } from "@/components/shared/stat-item";
+import { RoiTable } from "@/features/campaigns/components/roi/roi-table";
+import { CreateRoiDialog } from "@/features/campaigns/components/roi/create-roi-dialog";
+import { AddFundsDialog } from "@/features/campaigns/components/roi/add-funds-dialog";
+import { useRoi } from "@/features/campaigns/hooks/use-roi";
+import { MOCK_CAMPAIGNS } from "@/features/campaigns/mock/campaigns.mock";
+
+const SUMMARY_STATS = [
+ {
+ label: "Total Activo",
+ value: "$1,350,000",
+ description: "+12.5% vs mes anterior",
+ },
+ {
+ label: "Retorno Promedio",
+ value: "8.4%",
+ description: "Objetivo anual: 9.0%",
+ },
+ {
+ label: "Beneficiarios",
+ value: "1,248",
+ description: "+154 nuevos este mes",
+ },
+];
+
+export function RoiView() {
+ const {
+ roiDialogCampaign,
+ fundsDialogCampaign,
+ roiForm,
+ openRoiDialog,
+ closeRoiDialog,
+ openFundsDialog,
+ closeFundsDialog,
+ onSubmitRoi,
+ onFundNow,
+ } = useRoi();
+
+ return (
+
+ {/* Campaigns table section */}
+
+
+
+ Campaña de ROI Activas
+
+
+
+
+
+
+ {/* Financial summary */}
+
+
+ Resumen Financiero
+
+
+ {SUMMARY_STATS.map((stat) => (
+
+
+
+ ))}
+
+
+
+ {/* Dialogs */}
+
+
+
+ );
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts b/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts
new file mode 100644
index 0000000..04679ad
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/constants/campaign-status.ts
@@ -0,0 +1,12 @@
+import type { CampaignStatus } from "@/features/campaigns/types/campaign.types";
+
+export const CAMPAIGN_STATUS_CONFIG: Record<
+ CampaignStatus,
+ { label: string; className: string }
+> = {
+ active: { label: "Activa", className: "bg-success-bg text-success border-success/30" },
+ pending: { label: "Pendiente", className: "bg-yellow-50 text-yellow-700 border-yellow-200" },
+ draft: { label: "Borrador", className: "bg-secondary text-text-muted border-border" },
+ completed: { label: "Completada", className: "bg-secondary text-text-muted border-border" },
+ cancelled: { label: "Cancelada", className: "bg-destructive/10 text-destructive border-destructive/20" },
+};
diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/use-campaigns.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-campaigns.ts
new file mode 100644
index 0000000..3e2c77e
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-campaigns.ts
@@ -0,0 +1,11 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { getCampaigns } from "@/features/campaigns/services/campaigns.api";
+
+export function useCampaigns() {
+ return useQuery({
+ queryKey: ["campaigns"],
+ queryFn: getCampaigns,
+ });
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/use-create-campaign.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-create-campaign.ts
new file mode 100644
index 0000000..b2d2636
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-create-campaign.ts
@@ -0,0 +1,71 @@
+"use client";
+
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { useRouter } from "next/navigation";
+import type { CreateCampaignFormValues } from "@/features/campaigns/types/campaign.types";
+import { createCampaign } from "@/features/campaigns/services/campaigns.api";
+
+const TOTAL_STEPS = 3;
+
+const STEP_FIELDS: Record = {
+ 1: ["name", "description", "durationDays", "expectedRoi"],
+ 2: ["targetAmount"],
+ 3: ["tokenName", "tokenAsset", "investmentAmount"],
+};
+
+export function useCreateCampaign() {
+ const router = useRouter();
+ const [step, setStep] = React.useState(1);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ const form = useForm({
+ defaultValues: {
+ name: "",
+ description: "",
+ durationDays: 90,
+ expectedRoi: 0,
+ targetAmount: 0,
+ tokenName: "",
+ tokenAsset: "USDC",
+ investmentAmount: 0,
+ },
+ mode: "onChange",
+ });
+
+ const targetAmount = form.watch("targetAmount");
+ const totalCommitment = targetAmount;
+
+ const nextStep = async () => {
+ const valid = await form.trigger(STEP_FIELDS[step]);
+ if (valid) setStep((s) => Math.min(s + 1, TOTAL_STEPS));
+ };
+
+ const prevStep = () => setStep((s) => Math.max(s - 1, 1));
+
+ const onSubmit = form.handleSubmit(async (values) => {
+ setError(null);
+ setIsSubmitting(true);
+ try {
+ await createCampaign(values);
+ router.push("/campaigns");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Error inesperado");
+ } finally {
+ setIsSubmitting(false);
+ }
+ });
+
+ return {
+ form,
+ step,
+ totalSteps: TOTAL_STEPS,
+ nextStep,
+ prevStep,
+ isSubmitting,
+ error,
+ onSubmit,
+ totalCommitment,
+ };
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/use-manage-loans.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-manage-loans.ts
new file mode 100644
index 0000000..e6b63a5
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-manage-loans.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import * as React from "react";
+import { useForm } from "react-hook-form";
+import { useWalletContext } from "@tokenization/tw-blocks-shared/src/wallet-kit/WalletProvider";
+import type { AddMilestoneFormValues, Milestone } from "@/features/campaigns/types/milestone.types";
+
+const MOCK_MILESTONES: Milestone[] = [
+ { id: "1", description: "El ropero de liliana", walletAddress: "0xabc...111", amount: 2000, status: "active" },
+ { id: "2", description: "Peluqueria doña maria", walletAddress: "0xabc...222", amount: 2000, status: "active" },
+ { id: "3", description: "Restaurante el sabroso", walletAddress: "0xabc...333", amount: 2000, status: "active" },
+];
+
+export function useManageLoans() {
+ const { walletAddress } = useWalletContext();
+ const [milestones, setMilestones] = React.useState(MOCK_MILESTONES);
+ const [editingId, setEditingId] = React.useState(null);
+ const [editValues, setEditValues] = React.useState<{ description: string; amount: number }>({
+ description: "",
+ amount: 0,
+ });
+
+ const form = useForm({
+ defaultValues: { description: "", amount: 0 },
+ mode: "onChange",
+ });
+
+ const completeMilestone = (id: string) => {
+ setMilestones((prev) =>
+ prev.map((m) => (m.id === id ? { ...m, status: "completed" } : m))
+ );
+ };
+
+ const startEdit = (milestone: Milestone) => {
+ setEditingId(milestone.id);
+ setEditValues({ description: milestone.description, amount: milestone.amount });
+ };
+
+ const cancelEdit = () => setEditingId(null);
+
+ const saveEdit = () => {
+ if (!editingId) return;
+ setMilestones((prev) =>
+ prev.map((m) =>
+ m.id === editingId ? { ...m, ...editValues } : m
+ )
+ );
+ setEditingId(null);
+ };
+
+ const onSubmit = form.handleSubmit((values) => {
+ setMilestones((prev) => [
+ ...prev,
+ { id: Date.now().toString(), ...values, walletAddress: walletAddress ?? "", status: "active" },
+ ]);
+ form.reset();
+ });
+
+ return {
+ form,
+ milestones,
+ walletAddress,
+ editingId,
+ editValues,
+ setEditValues,
+ completeMilestone,
+ startEdit,
+ cancelEdit,
+ saveEdit,
+ onSubmit,
+ };
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts
new file mode 100644
index 0000000..223d9ef
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/hooks/use-roi.ts
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+
+export interface RoiFormValues {
+ roiPercentage: number;
+}
+
+export function useRoi() {
+ const [roiDialogCampaign, setRoiDialogCampaign] = useState(null);
+ const [fundsDialogCampaign, setFundsDialogCampaign] = useState(null);
+
+ const roiForm = useForm({
+ defaultValues: { roiPercentage: 0 },
+ });
+
+ function openRoiDialog(campaign: Campaign) {
+ roiForm.reset();
+ setRoiDialogCampaign(campaign);
+ }
+
+ function closeRoiDialog() {
+ setRoiDialogCampaign(null);
+ }
+
+ function openFundsDialog(campaign: Campaign) {
+ setFundsDialogCampaign(campaign);
+ }
+
+ function closeFundsDialog() {
+ setFundsDialogCampaign(null);
+ }
+
+ const onSubmitRoi = roiForm.handleSubmit((data) => {
+ console.log("Create ROI:", data, "for campaign:", roiDialogCampaign?.id);
+ closeRoiDialog();
+ });
+
+ function onFundNow() {
+ console.log("Fund now for campaign:", fundsDialogCampaign?.id);
+ closeFundsDialog();
+ }
+
+ return {
+ roiDialogCampaign,
+ fundsDialogCampaign,
+ roiForm,
+ openRoiDialog,
+ closeRoiDialog,
+ openFundsDialog,
+ closeFundsDialog,
+ onSubmitRoi,
+ onFundNow,
+ };
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/mock/campaigns.mock.ts b/apps/backoffice-tokenization/src/features/campaigns/mock/campaigns.mock.ts
new file mode 100644
index 0000000..36a5722
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/mock/campaigns.mock.ts
@@ -0,0 +1,100 @@
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+
+export const MOCK_CAMPAIGNS: Campaign[] = [
+ {
+ id: "1",
+ title: "Fondo de Crédito Productivo — Cali",
+ description:
+ "Financiamiento para pequeñas empresas del sector manufacturero en el Valle del Cauca, con retorno anual proyectado del 12%.",
+ status: "active",
+ targetAmount: 500_000,
+ raisedAmount: 320_000,
+ startDate: "2024-01-15",
+ endDate: "2024-07-15",
+ createdAt: "2024-01-10",
+ },
+ {
+ id: "2",
+ title: "Cartera Agropecuaria — Antioquia",
+ description:
+ "Crédito para productores agrícolas del Oriente Antioqueño. Garantía hipotecaria sobre predios rurales.",
+ status: "active",
+ targetAmount: 250_000,
+ raisedAmount: 250_000,
+ startDate: "2024-02-01",
+ endDate: "2024-08-01",
+ createdAt: "2024-01-25",
+ },
+ {
+ id: "3",
+ title: "Portafolio PyME — Bogotá",
+ description:
+ "Línea de crédito rotativo para empresas medianas del sector servicios en Bogotá. Plazo de 12 meses.",
+ status: "pending",
+ targetAmount: 800_000,
+ raisedAmount: 0,
+ startDate: "2024-04-01",
+ endDate: "2025-04-01",
+ createdAt: "2024-03-20",
+ },
+ {
+ id: "4",
+ title: "Infraestructura Turística — Cartagena",
+ description:
+ "Financiamiento para mejoras en hospedaje y turismo en la costa Caribe. Rentabilidad ligada a ocupación hotelera.",
+ status: "completed",
+ targetAmount: 300_000,
+ raisedAmount: 300_000,
+ startDate: "2023-06-01",
+ endDate: "2024-01-01",
+ createdAt: "2023-05-15",
+ },
+ {
+ id: "5",
+ title: "Crédito Comercial — Barranquilla",
+ description:
+ "Capital de trabajo para importadores de la zona franca de Barranquilla. Ciclo de 90 días renovable.",
+ status: "active",
+ targetAmount: 150_000,
+ raisedAmount: 90_000,
+ startDate: "2024-03-01",
+ endDate: "2024-09-01",
+ createdAt: "2024-02-20",
+ },
+ {
+ id: "6",
+ title: "Fondo Energías Renovables — Cundinamarca",
+ description:
+ "Proyectos de energía solar para industria. Co-financiado con el Ministerio de Energía.",
+ status: "cancelled",
+ targetAmount: 600_000,
+ raisedAmount: 80_000,
+ startDate: "2024-01-01",
+ endDate: "2024-06-01",
+ createdAt: "2023-12-01",
+ },
+ {
+ id: "7",
+ title: "Microcrédito Rural — Nariño",
+ description:
+ "Créditos de bajo monto para comunidades rurales del sur del país. Impacto social medible.",
+ status: "pending",
+ targetAmount: 100_000,
+ raisedAmount: 15_000,
+ startDate: "2024-05-01",
+ endDate: "2025-05-01",
+ createdAt: "2024-04-01",
+ },
+ {
+ id: "8",
+ title: "Cartera Inmobiliaria — Medellín",
+ description:
+ "Financiamiento puente para desarrolladores residenciales en el Área Metropolitana del Valle de Aburrá.",
+ status: "completed",
+ targetAmount: 1_000_000,
+ raisedAmount: 1_000_000,
+ startDate: "2023-03-01",
+ endDate: "2023-12-01",
+ createdAt: "2023-02-15",
+ },
+];
diff --git a/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts b/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts
new file mode 100644
index 0000000..13396b4
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/services/campaigns.api.ts
@@ -0,0 +1,23 @@
+import type { Campaign, CreateCampaignFormValues } from "@/features/campaigns/types/campaign.types";
+
+export async function getCampaigns(): Promise {
+ const res = await fetch("/api/campaigns");
+ if (!res.ok) throw new Error("Failed to fetch campaigns");
+ return res.json();
+}
+
+export async function getCampaignById(id: string): Promise {
+ const res = await fetch(`/api/campaigns/${id}`);
+ if (!res.ok) throw new Error("Failed to fetch campaign");
+ return res.json();
+}
+
+export async function createCampaign(data: CreateCampaignFormValues): Promise {
+ const res = await fetch("/api/campaigns", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) throw new Error("Failed to create campaign");
+ return res.json();
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/types/campaign.types.ts b/apps/backoffice-tokenization/src/features/campaigns/types/campaign.types.ts
new file mode 100644
index 0000000..24af11f
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/types/campaign.types.ts
@@ -0,0 +1,27 @@
+export type CampaignStatus = "active" | "completed" | "pending" | "draft" | "cancelled";
+
+export interface Campaign {
+ id: string;
+ title: string;
+ description: string;
+ status: CampaignStatus;
+ targetAmount: number;
+ raisedAmount: number;
+ startDate: string;
+ endDate: string;
+ createdAt: string;
+}
+
+export interface CreateCampaignFormValues {
+ // Step 1 – Campaign Basics
+ name: string;
+ description: string;
+ durationDays: number;
+ expectedRoi: number;
+ // Step 2 – Escrow Configuration
+ targetAmount: number;
+ // Step 3 – Create Token
+ tokenName: string;
+ tokenAsset: string;
+ investmentAmount: number;
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts b/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts
new file mode 100644
index 0000000..813cd26
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/types/milestone.types.ts
@@ -0,0 +1,14 @@
+export type MilestoneStatus = "active" | "completed";
+
+export interface Milestone {
+ id: string;
+ description: string;
+ walletAddress: string;
+ amount: number;
+ status: MilestoneStatus;
+}
+
+export interface AddMilestoneFormValues {
+ description: string;
+ amount: number;
+}
diff --git a/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts b/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts
new file mode 100644
index 0000000..4ab5645
--- /dev/null
+++ b/apps/backoffice-tokenization/src/features/campaigns/utils/campaign.mapper.ts
@@ -0,0 +1,6 @@
+import type { Campaign } from "@/features/campaigns/types/campaign.types";
+
+export function mapCampaignProgress(campaign: Campaign): number {
+ if (campaign.targetAmount === 0) return 0;
+ return Math.min((campaign.raisedAmount / campaign.targetAmount) * 100, 100);
+}
diff --git a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx
index 00fc9b0..2465c8f 100644
--- a/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx
+++ b/apps/backoffice-tokenization/src/features/vaults/deploy/dialog/CreateVault.tsx
@@ -19,6 +19,7 @@ import {
} from "@tokenization/ui/dialog";
import { Loader2 } from "lucide-react";
import { useCreateVault } from "./useCreateVault";
+import { numericInputKeyDown, parseNumericInput } from "@/lib/numeric-input";
import { useWatch } from "react-hook-form";
import { VaultDeploySuccessDialog } from "./VaultDeploySuccessDialog";
@@ -59,7 +60,10 @@ export const CreateVaultDialog = () => {
(
@@ -72,11 +76,13 @@ export const CreateVaultDialog = () => {
field.onChange(String(parseNumericInput(e.target.value, 100)))}
/>
diff --git a/apps/backoffice-tokenization/src/lib/numeric-input.ts b/apps/backoffice-tokenization/src/lib/numeric-input.ts
new file mode 100644
index 0000000..138d929
--- /dev/null
+++ b/apps/backoffice-tokenization/src/lib/numeric-input.ts
@@ -0,0 +1,20 @@
+import type { KeyboardEvent } from "react";
+
+const ALLOWED_KEYS = ["Backspace", "Delete", "ArrowLeft", "ArrowRight", "Tab"];
+
+export function numericInputKeyDown(e: KeyboardEvent) {
+ if (
+ !/[0-9.,]/.test(e.key) &&
+ !ALLOWED_KEYS.includes(e.key) &&
+ !e.ctrlKey &&
+ !e.metaKey
+ ) {
+ e.preventDefault();
+ }
+}
+
+export function parseNumericInput(value: string, max?: number): number {
+ const num = Number(value.replace(/[^0-9.,]/g, "").replace(",", "."));
+ const safe = isNaN(num) ? 0 : num;
+ return max !== undefined ? Math.min(safe, max) : safe;
+}
diff --git a/apps/backoffice-tokenization/src/lib/utils.ts b/apps/backoffice-tokenization/src/lib/utils.ts
index c66271b..16cc214 100644
--- a/apps/backoffice-tokenization/src/lib/utils.ts
+++ b/apps/backoffice-tokenization/src/lib/utils.ts
@@ -1 +1,8 @@
export { cn } from "@tokenization/shared/lib/utils";
+
+export function formatCurrency(amount: number, decimals = 0): string {
+ return amount.toLocaleString("es-CO", {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ });
+}