[TASK-MASTER] feat: FE-003 partners + FE-004 companies extensions

FE-003: Extended partners feature with:
- addresses API (CRUD endpoints)
- contacts API (CRUD endpoints)
- bankAccounts API (CRUD endpoints)
- AddressForm, AddressList, ContactForm, ContactList components
- usePartnerAddresses, usePartnerContacts, usePartnerBankAccounts hooks

FE-004: Extended companies feature with:
- settings endpoints (GET/PUT company settings)
- branches endpoints (CRUD branches)
- CompanySettingsForm, BranchesList components
- useCompanySettings, useCompanyBranches hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-04 00:29:50 -06:00
parent 158ebcb57b
commit b5cffbff5f
22 changed files with 2016 additions and 0 deletions

View File

@ -1,10 +1,13 @@
import { api } from '@services/api/axios-instance'; import { api } from '@services/api/axios-instance';
import type { import type {
Company, Company,
CompanySettings,
CreateCompanyDto, CreateCompanyDto,
UpdateCompanyDto, UpdateCompanyDto,
CompanyFilters, CompanyFilters,
CompaniesResponse, CompaniesResponse,
Branch,
BranchesResponse,
} from '../types'; } from '../types';
const BASE_URL = '/api/v1/companies'; const BASE_URL = '/api/v1/companies';
@ -52,4 +55,27 @@ export const companiesApi = {
const response = await api.get<Company[]>(`${BASE_URL}/${parentId}/children`); const response = await api.get<Company[]>(`${BASE_URL}/${parentId}/children`);
return response.data; return response.data;
}, },
// Get company settings
getSettings: async (id: string): Promise<CompanySettings> => {
const response = await api.get<{ success: boolean; data: Company }>(`${BASE_URL}/${id}`);
return response.data.data?.settings || {};
},
// Update company settings
updateSettings: async (id: string, settings: CompanySettings): Promise<CompanySettings> => {
const response = await api.put<{ success: boolean; data: Company }>(`${BASE_URL}/${id}`, { settings });
return response.data.data?.settings || {};
},
// Get branches for a company (filtered by tenant)
getBranches: async (companyId: string): Promise<BranchesResponse> => {
const response = await api.get<{ success: boolean; data: Branch[]; total?: number }>(
`/api/v1/branches?companyId=${companyId}`
);
return {
data: response.data.data || [],
total: response.data.total || response.data.data?.length || 0,
};
},
}; };

View File

@ -0,0 +1,147 @@
import { MapPin, Phone, Mail, Clock, Building2 } from 'lucide-react';
import { Badge } from '@components/atoms/Badge';
import { Card, CardContent } from '@components/molecules/Card';
import { Spinner } from '@components/atoms/Spinner';
import type { Branch, BranchType } from '../types';
const branchTypeLabels: Record<BranchType, string> = {
headquarters: 'Matriz',
regional: 'Regional',
store: 'Tienda',
warehouse: 'Almacen',
office: 'Oficina',
factory: 'Fabrica',
};
const branchTypeColors: Record<BranchType, 'default' | 'primary' | 'success' | 'warning' | 'danger'> = {
headquarters: 'primary',
regional: 'warning',
store: 'success',
warehouse: 'default',
office: 'default',
factory: 'default',
};
interface BranchesListProps {
branches: Branch[];
isLoading?: boolean;
emptyMessage?: string;
onBranchClick?: (branch: Branch) => void;
}
export function BranchesList({
branches,
isLoading,
emptyMessage = 'No hay sucursales registradas',
onBranchClick,
}: BranchesListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spinner size="lg" />
</div>
);
}
if (!branches || branches.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-gray-300" />
<p className="mt-2 text-gray-500">{emptyMessage}</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
{branches.map((branch) => (
<Card
key={branch.id}
className={onBranchClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}
onClick={() => onBranchClick?.(branch)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900">{branch.name}</h4>
<Badge variant={branchTypeColors[branch.branchType]}>
{branchTypeLabels[branch.branchType]}
</Badge>
{branch.isMain && (
<Badge variant="primary">Principal</Badge>
)}
{!branch.isActive && (
<Badge variant="default">Inactiva</Badge>
)}
</div>
{branch.shortName && (
<p className="mt-0.5 text-sm text-gray-500">
Codigo: {branch.code} | {branch.shortName}
</p>
)}
{!branch.shortName && (
<p className="mt-0.5 text-sm text-gray-500">Codigo: {branch.code}</p>
)}
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 text-sm text-gray-600 sm:grid-cols-2">
{(branch.addressLine1 || branch.city) && (
<div className="flex items-start gap-2">
<MapPin className="mt-0.5 h-4 w-4 flex-shrink-0 text-gray-400" />
<span>
{[branch.addressLine1, branch.city, branch.state]
.filter(Boolean)
.join(', ')}
</span>
</div>
)}
{branch.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-gray-400" />
<span>{branch.phone}</span>
</div>
)}
{branch.email && (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-gray-400" />
<span>{branch.email}</span>
</div>
)}
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400" />
<span>{branch.timezone}</span>
</div>
</div>
{branch.settings && (
<div className="mt-3 flex flex-wrap gap-2">
{branch.settings.allowPos && (
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs text-green-700">
POS
</span>
)}
{branch.settings.allowWarehouse && (
<span className="rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700">
Almacen
</span>
)}
{branch.settings.allowCheckIn && (
<span className="rounded-full bg-purple-50 px-2 py-0.5 text-xs text-purple-700">
Check-in
</span>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -0,0 +1,194 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import type { CompanySettings } from '../types';
const settingsSchema = z.object({
email: z.string().email('Email invalido').optional().or(z.literal('')),
phone: z.string().optional(),
website: z.string().url('URL invalida').optional().or(z.literal('')),
taxRegime: z.string().optional(),
fiscalPosition: z.string().optional(),
address: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
country: z.string().optional(),
zipCode: z.string().optional(),
});
type SettingsFormData = z.infer<typeof settingsSchema>;
interface CompanySettingsFormProps {
settings?: CompanySettings;
onSubmit: (data: CompanySettings) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
export function CompanySettingsForm({
settings,
onSubmit,
onCancel,
isLoading,
}: CompanySettingsFormProps) {
const {
register,
handleSubmit,
formState: { errors, isDirty },
} = useForm<SettingsFormData>({
resolver: zodResolver(settingsSchema),
defaultValues: {
email: settings?.email || '',
phone: settings?.phone || '',
website: settings?.website || '',
taxRegime: settings?.taxRegime || '',
fiscalPosition: settings?.fiscalPosition || '',
address: settings?.address || '',
city: settings?.city || '',
state: settings?.state || '',
country: settings?.country || '',
zipCode: settings?.zipCode || '',
},
});
const handleFormSubmit = async (data: SettingsFormData) => {
const cleanData: CompanySettings = {};
if (data.email) cleanData.email = data.email;
if (data.phone) cleanData.phone = data.phone;
if (data.website) cleanData.website = data.website;
if (data.taxRegime) cleanData.taxRegime = data.taxRegime;
if (data.fiscalPosition) cleanData.fiscalPosition = data.fiscalPosition;
if (data.address) cleanData.address = data.address;
if (data.city) cleanData.city = data.city;
if (data.state) cleanData.state = data.state;
if (data.country) cleanData.country = data.country;
if (data.zipCode) cleanData.zipCode = data.zipCode;
await onSubmit(cleanData);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Contact Info */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Informacion de contacto</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Email" error={errors.email?.message}>
<input
{...register('email')}
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="contacto@empresa.com"
/>
</FormField>
<FormField label="Telefono" error={errors.phone?.message}>
<input
{...register('phone')}
type="tel"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="+52 55 1234 5678"
/>
</FormField>
</div>
<FormField label="Sitio web" error={errors.website?.message}>
<input
{...register('website')}
type="url"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="https://www.empresa.com"
/>
</FormField>
</div>
{/* Fiscal Info */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Informacion fiscal</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Regimen fiscal" error={errors.taxRegime?.message}>
<input
{...register('taxRegime')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="601 - General de Ley"
/>
</FormField>
<FormField label="Posicion fiscal" error={errors.fiscalPosition?.message}>
<input
{...register('fiscalPosition')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="General"
/>
</FormField>
</div>
</div>
{/* Address */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Direccion</h3>
<FormField label="Direccion" error={errors.address?.message}>
<input
{...register('address')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Av. Principal #123, Col. Centro"
/>
</FormField>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<FormField label="Ciudad" error={errors.city?.message}>
<input
{...register('city')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Ciudad de Mexico"
/>
</FormField>
<FormField label="Estado" error={errors.state?.message}>
<input
{...register('state')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="CDMX"
/>
</FormField>
<FormField label="Pais" error={errors.country?.message}>
<input
{...register('country')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Mexico"
/>
</FormField>
<FormField label="C.P." error={errors.zipCode?.message}>
<input
{...register('zipCode')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="06600"
/>
</FormField>
</div>
</div>
<div className="flex justify-end gap-3 border-t pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" isLoading={isLoading} disabled={!isDirty}>
Guardar configuracion
</Button>
</div>
</form>
);
}

View File

@ -1,2 +1,4 @@
export * from './CompanyForm'; export * from './CompanyForm';
export * from './CompanyFiltersPanel'; export * from './CompanyFiltersPanel';
export * from './CompanySettingsForm';
export * from './BranchesList';

View File

@ -1 +1,3 @@
export * from './useCompanies'; export * from './useCompanies';
export * from './useCompanySettings';
export * from './useCompanyBranches';

View File

@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';
import { companiesApi } from '../api';
import type { BranchesResponse } from '../types';
const QUERY_KEY = 'companies';
// ==================== Company Branches Hook ====================
export function useCompanyBranches(companyId: string | null | undefined) {
return useQuery({
queryKey: [QUERY_KEY, 'branches', companyId],
queryFn: () => companiesApi.getBranches(companyId as string),
enabled: !!companyId,
staleTime: 1000 * 60 * 5, // 5 minutes
select: (response: BranchesResponse) => ({
branches: response.data,
total: response.total,
}),
});
}

View File

@ -0,0 +1,38 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { companiesApi } from '../api';
import type { CompanySettings } from '../types';
const QUERY_KEY = 'companies';
// ==================== Company Settings Hook ====================
export function useCompanySettings(companyId: string | null | undefined) {
return useQuery({
queryKey: [QUERY_KEY, 'settings', companyId],
queryFn: () => companiesApi.getSettings(companyId as string),
enabled: !!companyId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== Company Settings Mutations Hook ====================
export function useCompanySettingsMutations() {
const queryClient = useQueryClient();
const updateMutation = useMutation({
mutationFn: ({ companyId, settings }: { companyId: string; settings: CompanySettings }) =>
companiesApi.updateSettings(companyId, settings),
onSuccess: (_: unknown, variables: { companyId: string; settings: CompanySettings }) => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'settings', variables.companyId] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, 'detail', variables.companyId] });
},
});
return {
updateSettings: updateMutation.mutateAsync,
isUpdating: updateMutation.isPending,
updateError: updateMutation.error,
};
}

View File

@ -67,3 +67,49 @@ export interface CompaniesResponse {
totalPages: number; totalPages: number;
}; };
} }
// Branch types for company branches listing
export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory';
export interface Branch {
id: string;
tenantId: string;
parentId?: string | null;
code: string;
name: string;
shortName?: string | null;
branchType: BranchType;
phone?: string | null;
email?: string | null;
managerId?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
city?: string | null;
state?: string | null;
postalCode?: string | null;
country: string;
latitude?: number | null;
longitude?: number | null;
geofenceRadius: number;
geofenceEnabled: boolean;
timezone: string;
currency: string;
isActive: boolean;
isMain: boolean;
operatingHours: Record<string, { open: string; close: string }>;
settings: {
allowPos?: boolean;
allowWarehouse?: boolean;
allowCheckIn?: boolean;
[key: string]: unknown;
};
hierarchyPath?: string | null;
hierarchyLevel: number;
createdAt: string;
updatedAt: string;
}
export interface BranchesResponse {
data: Branch[];
total: number;
}

View File

@ -0,0 +1,67 @@
import { api } from '@services/api/axios-instance';
import type {
PartnerAddress,
CreatePartnerAddressDto,
UpdatePartnerAddressDto,
} from '../types';
const BASE_URL = '/api/v1/partners';
interface AddressesResponse {
data: PartnerAddress[];
}
interface AddressResponse {
data: PartnerAddress;
}
export const addressesApi = {
/**
* Get all addresses for a partner
*/
getByPartnerId: async (partnerId: string): Promise<PartnerAddress[]> => {
const response = await api.get<AddressesResponse>(`${BASE_URL}/${partnerId}/addresses`);
return response.data.data || [];
},
/**
* Create a new address for a partner
*/
create: async (partnerId: string, data: CreatePartnerAddressDto): Promise<PartnerAddress> => {
const response = await api.post<AddressResponse>(`${BASE_URL}/${partnerId}/addresses`, data);
return response.data.data;
},
/**
* Update an address
*/
update: async (
partnerId: string,
addressId: string,
data: UpdatePartnerAddressDto
): Promise<PartnerAddress> => {
const response = await api.patch<AddressResponse>(
`${BASE_URL}/${partnerId}/addresses/${addressId}`,
data
);
return response.data.data;
},
/**
* Delete an address
*/
delete: async (partnerId: string, addressId: string): Promise<void> => {
await api.delete(`${BASE_URL}/${partnerId}/addresses/${addressId}`);
},
/**
* Set an address as default
*/
setDefault: async (partnerId: string, addressId: string): Promise<PartnerAddress> => {
const response = await api.patch<AddressResponse>(
`${BASE_URL}/${partnerId}/addresses/${addressId}`,
{ isDefault: true }
);
return response.data.data;
},
};

View File

@ -0,0 +1,83 @@
import { api } from '@services/api/axios-instance';
import type {
PartnerBankAccount,
CreatePartnerBankAccountDto,
UpdatePartnerBankAccountDto,
} from '../types';
const BASE_URL = '/api/v1/partners';
interface BankAccountsResponse {
data: PartnerBankAccount[];
}
interface BankAccountResponse {
data: PartnerBankAccount;
}
export const bankAccountsApi = {
/**
* Get all bank accounts for a partner
*/
getByPartnerId: async (partnerId: string): Promise<PartnerBankAccount[]> => {
const response = await api.get<BankAccountsResponse>(`${BASE_URL}/${partnerId}/bank-accounts`);
return response.data.data || [];
},
/**
* Create a new bank account for a partner
*/
create: async (
partnerId: string,
data: CreatePartnerBankAccountDto
): Promise<PartnerBankAccount> => {
const response = await api.post<BankAccountResponse>(
`${BASE_URL}/${partnerId}/bank-accounts`,
data
);
return response.data.data;
},
/**
* Update a bank account
*/
update: async (
partnerId: string,
accountId: string,
data: UpdatePartnerBankAccountDto
): Promise<PartnerBankAccount> => {
const response = await api.patch<BankAccountResponse>(
`${BASE_URL}/${partnerId}/bank-accounts/${accountId}`,
data
);
return response.data.data;
},
/**
* Delete a bank account
*/
delete: async (partnerId: string, accountId: string): Promise<void> => {
await api.delete(`${BASE_URL}/${partnerId}/bank-accounts/${accountId}`);
},
/**
* Verify a bank account
*/
verify: async (partnerId: string, accountId: string): Promise<PartnerBankAccount> => {
const response = await api.post<BankAccountResponse>(
`${BASE_URL}/${partnerId}/bank-accounts/${accountId}/verify`
);
return response.data.data;
},
/**
* Set a bank account as default
*/
setDefault: async (partnerId: string, accountId: string): Promise<PartnerBankAccount> => {
const response = await api.patch<BankAccountResponse>(
`${BASE_URL}/${partnerId}/bank-accounts/${accountId}`,
{ isDefault: true }
);
return response.data.data;
},
};

View File

@ -0,0 +1,67 @@
import { api } from '@services/api/axios-instance';
import type {
PartnerContact,
CreatePartnerContactDto,
UpdatePartnerContactDto,
} from '../types';
const BASE_URL = '/api/v1/partners';
interface ContactsResponse {
data: PartnerContact[];
}
interface ContactResponse {
data: PartnerContact;
}
export const contactsApi = {
/**
* Get all contacts for a partner
*/
getByPartnerId: async (partnerId: string): Promise<PartnerContact[]> => {
const response = await api.get<ContactsResponse>(`${BASE_URL}/${partnerId}/contacts`);
return response.data.data || [];
},
/**
* Create a new contact for a partner
*/
create: async (partnerId: string, data: CreatePartnerContactDto): Promise<PartnerContact> => {
const response = await api.post<ContactResponse>(`${BASE_URL}/${partnerId}/contacts`, data);
return response.data.data;
},
/**
* Update a contact
*/
update: async (
partnerId: string,
contactId: string,
data: UpdatePartnerContactDto
): Promise<PartnerContact> => {
const response = await api.patch<ContactResponse>(
`${BASE_URL}/${partnerId}/contacts/${contactId}`,
data
);
return response.data.data;
},
/**
* Delete a contact
*/
delete: async (partnerId: string, contactId: string): Promise<void> => {
await api.delete(`${BASE_URL}/${partnerId}/contacts/${contactId}`);
},
/**
* Set a contact as primary
*/
setPrimary: async (partnerId: string, contactId: string): Promise<PartnerContact> => {
const response = await api.patch<ContactResponse>(
`${BASE_URL}/${partnerId}/contacts/${contactId}`,
{ isPrimary: true }
);
return response.data.data;
},
};

View File

@ -1 +1,4 @@
export * from './partners.api'; export * from './partners.api';
export * from './addresses.api';
export * from './contacts.api';
export * from './bankAccounts.api';

View File

@ -0,0 +1,285 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import { Select } from '@components/organisms/Select';
import type { PartnerAddress, CreatePartnerAddressDto, UpdatePartnerAddressDto, AddressType } from '../types';
const addressSchema = z.object({
addressType: z.enum(['billing', 'shipping', 'both'] as const),
isDefault: z.boolean(),
label: z.string().max(100).optional(),
street: z.string().min(1, 'La calle es requerida').max(200),
exteriorNumber: z.string().max(20).optional(),
interiorNumber: z.string().max(20).optional(),
neighborhood: z.string().max(100).optional(),
city: z.string().min(1, 'La ciudad es requerida').max(100),
municipality: z.string().max(100).optional(),
state: z.string().min(1, 'El estado es requerido').max(100),
postalCode: z.string().min(1, 'El codigo postal es requerido').max(10),
country: z.string().max(3).optional(),
reference: z.string().optional(),
});
type FormData = z.infer<typeof addressSchema>;
interface AddressFormProps {
address?: PartnerAddress;
onSubmit: (data: CreatePartnerAddressDto | UpdatePartnerAddressDto) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
const addressTypeOptions = [
{ value: 'billing', label: 'Facturacion' },
{ value: 'shipping', label: 'Envio' },
{ value: 'both', label: 'Ambos' },
];
const mexicanStates = [
{ value: 'AGS', label: 'Aguascalientes' },
{ value: 'BC', label: 'Baja California' },
{ value: 'BCS', label: 'Baja California Sur' },
{ value: 'CAM', label: 'Campeche' },
{ value: 'CHIS', label: 'Chiapas' },
{ value: 'CHIH', label: 'Chihuahua' },
{ value: 'CDMX', label: 'Ciudad de Mexico' },
{ value: 'COAH', label: 'Coahuila' },
{ value: 'COL', label: 'Colima' },
{ value: 'DGO', label: 'Durango' },
{ value: 'GTO', label: 'Guanajuato' },
{ value: 'GRO', label: 'Guerrero' },
{ value: 'HGO', label: 'Hidalgo' },
{ value: 'JAL', label: 'Jalisco' },
{ value: 'MEX', label: 'Estado de Mexico' },
{ value: 'MICH', label: 'Michoacan' },
{ value: 'MOR', label: 'Morelos' },
{ value: 'NAY', label: 'Nayarit' },
{ value: 'NL', label: 'Nuevo Leon' },
{ value: 'OAX', label: 'Oaxaca' },
{ value: 'PUE', label: 'Puebla' },
{ value: 'QRO', label: 'Queretaro' },
{ value: 'QROO', label: 'Quintana Roo' },
{ value: 'SLP', label: 'San Luis Potosi' },
{ value: 'SIN', label: 'Sinaloa' },
{ value: 'SON', label: 'Sonora' },
{ value: 'TAB', label: 'Tabasco' },
{ value: 'TAMPS', label: 'Tamaulipas' },
{ value: 'TLAX', label: 'Tlaxcala' },
{ value: 'VER', label: 'Veracruz' },
{ value: 'YUC', label: 'Yucatan' },
{ value: 'ZAC', label: 'Zacatecas' },
];
export function AddressForm({ address, onSubmit, onCancel, isLoading }: AddressFormProps) {
const isEditing = !!address;
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(addressSchema),
defaultValues: address
? {
addressType: address.addressType,
isDefault: address.isDefault,
label: address.label || '',
street: address.street,
exteriorNumber: address.exteriorNumber || '',
interiorNumber: address.interiorNumber || '',
neighborhood: address.neighborhood || '',
city: address.city,
municipality: address.municipality || '',
state: address.state,
postalCode: address.postalCode,
country: address.country || 'MEX',
reference: address.reference || '',
}
: {
addressType: 'billing' as AddressType,
isDefault: false,
label: '',
street: '',
exteriorNumber: '',
interiorNumber: '',
neighborhood: '',
city: '',
municipality: '',
state: '',
postalCode: '',
country: 'MEX',
reference: '',
},
});
const selectedType = watch('addressType');
const selectedState = watch('state');
const handleFormSubmit = async (data: FormData) => {
const cleanData: CreatePartnerAddressDto | UpdatePartnerAddressDto = {
addressType: data.addressType,
isDefault: data.isDefault,
street: data.street,
city: data.city,
state: data.state,
postalCode: data.postalCode,
...(data.label && { label: data.label }),
...(data.exteriorNumber && { exteriorNumber: data.exteriorNumber }),
...(data.interiorNumber && { interiorNumber: data.interiorNumber }),
...(data.neighborhood && { neighborhood: data.neighborhood }),
...(data.municipality && { municipality: data.municipality }),
...(data.country && { country: data.country }),
...(data.reference && { reference: data.reference }),
};
await onSubmit(cleanData);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Address Type */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Tipo de direccion</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Tipo" error={errors.addressType?.message} required>
<Select
options={addressTypeOptions}
value={selectedType}
onChange={(value) => setValue('addressType', value as AddressType)}
placeholder="Seleccionar tipo..."
/>
</FormField>
<FormField label="Etiqueta" error={errors.label?.message}>
<input
{...register('label')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Ej: Oficina principal"
/>
</FormField>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('isDefault')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Direccion predeterminada</span>
</label>
</div>
{/* Street Address */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Direccion</h3>
<FormField label="Calle" error={errors.street?.message} required>
<input
{...register('street')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Nombre de la calle"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<FormField label="No. Exterior" error={errors.exteriorNumber?.message}>
<input
{...register('exteriorNumber')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="123"
/>
</FormField>
<FormField label="No. Interior" error={errors.interiorNumber?.message}>
<input
{...register('interiorNumber')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="A"
/>
</FormField>
<FormField label="Colonia" error={errors.neighborhood?.message}>
<input
{...register('neighborhood')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Nombre de la colonia"
/>
</FormField>
</div>
</div>
{/* Location */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Ubicacion</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Ciudad" error={errors.city?.message} required>
<input
{...register('city')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Ciudad"
/>
</FormField>
<FormField label="Municipio" error={errors.municipality?.message}>
<input
{...register('municipality')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Municipio o delegacion"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Estado" error={errors.state?.message} required>
<Select
options={mexicanStates}
value={selectedState}
onChange={(value) => setValue('state', value as string)}
placeholder="Seleccionar estado..."
/>
</FormField>
<FormField label="Codigo Postal" error={errors.postalCode?.message} required>
<input
{...register('postalCode')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="12345"
maxLength={10}
/>
</FormField>
</div>
<FormField label="Referencia" error={errors.reference?.message}>
<textarea
{...register('reference')}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Entre calles, cerca de..."
/>
</FormField>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" isLoading={isLoading}>
{isEditing ? 'Guardar cambios' : 'Agregar direccion'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,160 @@
import { useState } from 'react';
import { Button } from '@components/atoms/Button';
import type { PartnerAddress } from '../types';
interface AddressListProps {
addresses: PartnerAddress[];
isLoading?: boolean;
onAdd: () => void;
onEdit: (address: PartnerAddress) => void;
onDelete: (addressId: string) => Promise<void>;
onSetDefault: (addressId: string) => Promise<void>;
}
const addressTypeLabels: Record<string, string> = {
billing: 'Facturacion',
shipping: 'Envio',
both: 'Ambos',
};
function AddressCard({
address,
onEdit,
onDelete,
onSetDefault,
}: {
address: PartnerAddress;
onEdit: () => void;
onDelete: () => void;
onSetDefault: () => void;
}) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Esta seguro de eliminar esta direccion?')) return;
setIsDeleting(true);
try {
await onDelete();
} finally {
setIsDeleting(false);
}
};
return (
<div className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<span className="inline-flex items-center rounded-full bg-primary-50 px-2 py-1 text-xs font-medium text-primary-700">
{addressTypeLabels[address.addressType]}
</span>
{address.isDefault && (
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
Predeterminada
</span>
)}
{address.label && (
<span className="text-sm text-gray-500">{address.label}</span>
)}
</div>
<p className="text-sm text-gray-900 font-medium">
{address.street}
{address.exteriorNumber && ` #${address.exteriorNumber}`}
{address.interiorNumber && ` Int. ${address.interiorNumber}`}
</p>
{address.neighborhood && (
<p className="text-sm text-gray-600">Col. {address.neighborhood}</p>
)}
<p className="text-sm text-gray-600">
{address.city}
{address.municipality && `, ${address.municipality}`}
</p>
<p className="text-sm text-gray-600">
{address.state}, C.P. {address.postalCode}
</p>
{address.reference && (
<p className="text-sm text-gray-500 mt-2 italic">
Ref: {address.reference}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Button size="sm" variant="ghost" onClick={onEdit}>
Editar
</Button>
{!address.isDefault && (
<Button size="sm" variant="ghost" onClick={onSetDefault}>
Predeterminar
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleDelete}
isLoading={isDeleting}
>
Eliminar
</Button>
</div>
</div>
</div>
);
}
export function AddressList({
addresses,
isLoading,
onAdd,
onEdit,
onDelete,
onSetDefault,
}: AddressListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">
Direcciones ({addresses.length})
</h3>
<Button onClick={onAdd} size="sm">
Agregar direccion
</Button>
</div>
{addresses.length === 0 ? (
<div className="text-center py-8 border border-dashed border-gray-300 rounded-lg">
<p className="text-gray-500 mb-4">No hay direcciones registradas</p>
<Button onClick={onAdd} variant="outline" size="sm">
Agregar primera direccion
</Button>
</div>
) : (
<div className="grid gap-4">
{addresses.map((address) => (
<AddressCard
key={address.id}
address={address}
onEdit={() => onEdit(address)}
onDelete={() => onDelete(address.id)}
onSetDefault={() => onSetDefault(address.id)}
/>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,238 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@components/atoms/Button';
import { FormField } from '@components/molecules/FormField';
import type { PartnerContact, CreatePartnerContactDto, UpdatePartnerContactDto } from '../types';
const contactSchema = z.object({
fullName: z.string().min(2, 'Minimo 2 caracteres').max(200),
position: z.string().max(100).optional(),
department: z.string().max(100).optional(),
email: z.string().email('Email invalido').optional().or(z.literal('')),
phone: z.string().max(30).optional(),
mobile: z.string().max(30).optional(),
extension: z.string().max(30).optional(),
isPrimary: z.boolean(),
isBillingContact: z.boolean(),
isShippingContact: z.boolean(),
receivesNotifications: z.boolean(),
notes: z.string().optional(),
});
type FormData = z.infer<typeof contactSchema>;
interface ContactFormProps {
contact?: PartnerContact;
onSubmit: (data: CreatePartnerContactDto | UpdatePartnerContactDto) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
export function ContactForm({ contact, onSubmit, onCancel, isLoading }: ContactFormProps) {
const isEditing = !!contact;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(contactSchema),
defaultValues: contact
? {
fullName: contact.fullName,
position: contact.position || '',
department: contact.department || '',
email: contact.email || '',
phone: contact.phone || '',
mobile: contact.mobile || '',
extension: contact.extension || '',
isPrimary: contact.isPrimary,
isBillingContact: contact.isBillingContact,
isShippingContact: contact.isShippingContact,
receivesNotifications: contact.receivesNotifications,
notes: contact.notes || '',
}
: {
fullName: '',
position: '',
department: '',
email: '',
phone: '',
mobile: '',
extension: '',
isPrimary: false,
isBillingContact: false,
isShippingContact: false,
receivesNotifications: true,
notes: '',
},
});
const handleFormSubmit = async (data: FormData) => {
const cleanData: CreatePartnerContactDto | UpdatePartnerContactDto = {
fullName: data.fullName,
isPrimary: data.isPrimary,
isBillingContact: data.isBillingContact,
isShippingContact: data.isShippingContact,
receivesNotifications: data.receivesNotifications,
...(data.position && { position: data.position }),
...(data.department && { department: data.department }),
...(data.email && { email: data.email }),
...(data.phone && { phone: data.phone }),
...(data.mobile && { mobile: data.mobile }),
...(data.extension && { extension: data.extension }),
...(data.notes && { notes: data.notes }),
};
await onSubmit(cleanData);
};
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Informacion basica</h3>
<FormField label="Nombre completo" error={errors.fullName?.message} required>
<input
{...register('fullName')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Nombre del contacto"
/>
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Puesto" error={errors.position?.message}>
<input
{...register('position')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Ej: Gerente de compras"
/>
</FormField>
<FormField label="Departamento" error={errors.department?.message}>
<input
{...register('department')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Ej: Compras"
/>
</FormField>
</div>
</div>
{/* Contact Info */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Informacion de contacto</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Email" error={errors.email?.message}>
<input
{...register('email')}
type="email"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="email@ejemplo.com"
/>
</FormField>
<FormField label="Telefono" error={errors.phone?.message}>
<input
{...register('phone')}
type="tel"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="+52 55 1234 5678"
/>
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Movil" error={errors.mobile?.message}>
<input
{...register('mobile')}
type="tel"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="+52 55 8765 4321"
/>
</FormField>
<FormField label="Extension" error={errors.extension?.message}>
<input
{...register('extension')}
type="text"
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="123"
/>
</FormField>
</div>
</div>
{/* Roles */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Roles del contacto</h3>
<div className="space-y-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('isPrimary')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Contacto principal</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('isBillingContact')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Contacto de facturacion</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('isShippingContact')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Contacto de envios</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
{...register('receivesNotifications')}
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Recibe notificaciones</span>
</label>
</div>
</div>
{/* Notes */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900">Notas</h3>
<FormField label="Notas" error={errors.notes?.message}>
<textarea
{...register('notes')}
rows={3}
className="w-full rounded-lg border border-gray-300 px-3 py-2 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
placeholder="Notas adicionales sobre el contacto..."
/>
</FormField>
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" isLoading={isLoading}>
{isEditing ? 'Guardar cambios' : 'Agregar contacto'}
</Button>
</div>
</form>
);
}

View File

@ -0,0 +1,192 @@
import { useState } from 'react';
import { Button } from '@components/atoms/Button';
import type { PartnerContact } from '../types';
interface ContactListProps {
contacts: PartnerContact[];
isLoading?: boolean;
onAdd: () => void;
onEdit: (contact: PartnerContact) => void;
onDelete: (contactId: string) => Promise<void>;
onSetPrimary: (contactId: string) => Promise<void>;
}
function ContactCard({
contact,
onEdit,
onDelete,
onSetPrimary,
}: {
contact: PartnerContact;
onEdit: () => void;
onDelete: () => void;
onSetPrimary: () => void;
}) {
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Esta seguro de eliminar este contacto?')) return;
setIsDeleting(true);
try {
await onDelete();
} finally {
setIsDeleting(false);
}
};
const roles: string[] = [];
if (contact.isPrimary) roles.push('Principal');
if (contact.isBillingContact) roles.push('Facturacion');
if (contact.isShippingContact) roles.push('Envios');
return (
<div className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className="text-sm font-semibold text-gray-900">{contact.fullName}</h4>
{contact.isPrimary && (
<span className="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700">
Principal
</span>
)}
</div>
{contact.position && (
<p className="text-sm text-gray-600">
{contact.position}
{contact.department && ` - ${contact.department}`}
</p>
)}
<div className="mt-2 space-y-1">
{contact.email && (
<p className="text-sm text-gray-600">
<span className="font-medium">Email:</span>{' '}
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:underline">
{contact.email}
</a>
</p>
)}
{contact.phone && (
<p className="text-sm text-gray-600">
<span className="font-medium">Tel:</span>{' '}
<a href={`tel:${contact.phone}`} className="text-primary-600 hover:underline">
{contact.phone}
</a>
{contact.extension && ` ext. ${contact.extension}`}
</p>
)}
{contact.mobile && (
<p className="text-sm text-gray-600">
<span className="font-medium">Movil:</span>{' '}
<a href={`tel:${contact.mobile}`} className="text-primary-600 hover:underline">
{contact.mobile}
</a>
</p>
)}
</div>
{roles.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{contact.isBillingContact && (
<span className="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
Facturacion
</span>
)}
{contact.isShippingContact && (
<span className="inline-flex items-center rounded-full bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700">
Envios
</span>
)}
{contact.receivesNotifications && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
Notificaciones
</span>
)}
</div>
)}
{contact.notes && (
<p className="text-sm text-gray-500 mt-2 italic">
{contact.notes}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<Button size="sm" variant="ghost" onClick={onEdit}>
Editar
</Button>
{!contact.isPrimary && (
<Button size="sm" variant="ghost" onClick={onSetPrimary}>
Hacer principal
</Button>
)}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={handleDelete}
isLoading={isDeleting}
>
Eliminar
</Button>
</div>
</div>
</div>
);
}
export function ContactList({
contacts,
isLoading,
onAdd,
onEdit,
onDelete,
onSetPrimary,
}: ContactListProps) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900">
Contactos ({contacts.length})
</h3>
<Button onClick={onAdd} size="sm">
Agregar contacto
</Button>
</div>
{contacts.length === 0 ? (
<div className="text-center py-8 border border-dashed border-gray-300 rounded-lg">
<p className="text-gray-500 mb-4">No hay contactos registrados</p>
<Button onClick={onAdd} variant="outline" size="sm">
Agregar primer contacto
</Button>
</div>
) : (
<div className="grid gap-4">
{contacts.map((contact) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={() => onEdit(contact)}
onDelete={() => onDelete(contact.id)}
onSetPrimary={() => onSetPrimary(contact.id)}
/>
))}
</div>
)}
</div>
);
}

View File

@ -2,3 +2,7 @@ export * from './PartnerForm';
export * from './PartnerFiltersPanel'; export * from './PartnerFiltersPanel';
export * from './PartnerTypeBadge'; export * from './PartnerTypeBadge';
export * from './PartnerStatusBadge'; export * from './PartnerStatusBadge';
export * from './AddressForm';
export * from './AddressList';
export * from './ContactForm';
export * from './ContactList';

View File

@ -1 +1,4 @@
export * from './usePartners'; export * from './usePartners';
export * from './usePartnerAddresses';
export * from './usePartnerContacts';
export * from './usePartnerBankAccounts';

View File

@ -0,0 +1,88 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { addressesApi } from '../api/addresses.api';
import type {
CreatePartnerAddressDto,
UpdatePartnerAddressDto,
} from '../types';
const QUERY_KEY = 'partner-addresses';
// ==================== Addresses List Hook ====================
export function usePartnerAddresses(partnerId: string | null | undefined) {
return useQuery({
queryKey: [QUERY_KEY, partnerId],
queryFn: () => addressesApi.getByPartnerId(partnerId as string),
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== Address Mutations Hook ====================
export function usePartnerAddressMutations(partnerId: string) {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: CreatePartnerAddressDto) => addressesApi.create(partnerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ addressId, data }: { addressId: string; data: UpdatePartnerAddressDto }) =>
addressesApi.update(partnerId, addressId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const deleteMutation = useMutation({
mutationFn: (addressId: string) => addressesApi.delete(partnerId, addressId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const setDefaultMutation = useMutation({
mutationFn: (addressId: string) => addressesApi.setDefault(partnerId, addressId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
return {
create: createMutation.mutateAsync,
update: updateMutation.mutateAsync,
delete: deleteMutation.mutateAsync,
setDefault: setDefaultMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isSettingDefault: setDefaultMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
};
}
// ==================== Combined Hook ====================
export function usePartnerAddressesWithMutations(partnerId: string | null | undefined) {
const { data, isLoading, error, refetch } = usePartnerAddresses(partnerId);
const mutations = usePartnerAddressMutations(partnerId || '');
return {
addresses: data || [],
isLoading,
error,
refetch,
...mutations,
// Disable mutations if no partnerId
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
};
}

View File

@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bankAccountsApi } from '../api/bankAccounts.api';
import type {
CreatePartnerBankAccountDto,
UpdatePartnerBankAccountDto,
} from '../types';
const QUERY_KEY = 'partner-bank-accounts';
// ==================== Bank Accounts List Hook ====================
export function usePartnerBankAccounts(partnerId: string | null | undefined) {
return useQuery({
queryKey: [QUERY_KEY, partnerId],
queryFn: () => bankAccountsApi.getByPartnerId(partnerId as string),
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== Bank Account Mutations Hook ====================
export function usePartnerBankAccountMutations(partnerId: string) {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: CreatePartnerBankAccountDto) => bankAccountsApi.create(partnerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ accountId, data }: { accountId: string; data: UpdatePartnerBankAccountDto }) =>
bankAccountsApi.update(partnerId, accountId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const deleteMutation = useMutation({
mutationFn: (accountId: string) => bankAccountsApi.delete(partnerId, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const verifyMutation = useMutation({
mutationFn: (accountId: string) => bankAccountsApi.verify(partnerId, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const setDefaultMutation = useMutation({
mutationFn: (accountId: string) => bankAccountsApi.setDefault(partnerId, accountId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
return {
create: createMutation.mutateAsync,
update: updateMutation.mutateAsync,
delete: deleteMutation.mutateAsync,
verify: verifyMutation.mutateAsync,
setDefault: setDefaultMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isVerifying: verifyMutation.isPending,
isSettingDefault: setDefaultMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
};
}
// ==================== Combined Hook ====================
export function usePartnerBankAccountsWithMutations(partnerId: string | null | undefined) {
const { data, isLoading, error, refetch } = usePartnerBankAccounts(partnerId);
const mutations = usePartnerBankAccountMutations(partnerId || '');
return {
bankAccounts: data || [],
isLoading,
error,
refetch,
...mutations,
// Disable mutations if no partnerId
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
verify: partnerId ? mutations.verify : async () => { throw new Error('Partner ID required'); },
setDefault: partnerId ? mutations.setDefault : async () => { throw new Error('Partner ID required'); },
};
}

View File

@ -0,0 +1,88 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contactsApi } from '../api/contacts.api';
import type {
CreatePartnerContactDto,
UpdatePartnerContactDto,
} from '../types';
const QUERY_KEY = 'partner-contacts';
// ==================== Contacts List Hook ====================
export function usePartnerContacts(partnerId: string | null | undefined) {
return useQuery({
queryKey: [QUERY_KEY, partnerId],
queryFn: () => contactsApi.getByPartnerId(partnerId as string),
enabled: !!partnerId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// ==================== Contact Mutations Hook ====================
export function usePartnerContactMutations(partnerId: string) {
const queryClient = useQueryClient();
const createMutation = useMutation({
mutationFn: (data: CreatePartnerContactDto) => contactsApi.create(partnerId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const updateMutation = useMutation({
mutationFn: ({ contactId, data }: { contactId: string; data: UpdatePartnerContactDto }) =>
contactsApi.update(partnerId, contactId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const deleteMutation = useMutation({
mutationFn: (contactId: string) => contactsApi.delete(partnerId, contactId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
const setPrimaryMutation = useMutation({
mutationFn: (contactId: string) => contactsApi.setPrimary(partnerId, contactId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY, partnerId] });
},
});
return {
create: createMutation.mutateAsync,
update: updateMutation.mutateAsync,
delete: deleteMutation.mutateAsync,
setPrimary: setPrimaryMutation.mutateAsync,
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isSettingPrimary: setPrimaryMutation.isPending,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
};
}
// ==================== Combined Hook ====================
export function usePartnerContactsWithMutations(partnerId: string | null | undefined) {
const { data, isLoading, error, refetch } = usePartnerContacts(partnerId);
const mutations = usePartnerContactMutations(partnerId || '');
return {
contacts: data || [],
isLoading,
error,
refetch,
...mutations,
// Disable mutations if no partnerId
create: partnerId ? mutations.create : async () => { throw new Error('Partner ID required'); },
update: partnerId ? mutations.update : async () => { throw new Error('Partner ID required'); },
delete: partnerId ? mutations.delete : async () => { throw new Error('Partner ID required'); },
setPrimary: partnerId ? mutations.setPrimary : async () => { throw new Error('Partner ID required'); },
};
}

View File

@ -100,3 +100,168 @@ export interface PartnersResponse {
totalPages: number; totalPages: number;
}; };
} }
// ==================== Partner Address Types ====================
export type AddressType = 'billing' | 'shipping' | 'both';
export interface PartnerAddress {
id: string;
partnerId: string;
addressType: AddressType;
isDefault: boolean;
label?: string | null;
street: string;
exteriorNumber?: string | null;
interiorNumber?: string | null;
neighborhood?: string | null;
city: string;
municipality?: string | null;
state: string;
postalCode: string;
country: string;
reference?: string | null;
latitude?: number | null;
longitude?: number | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreatePartnerAddressDto {
addressType?: AddressType;
isDefault?: boolean;
label?: string;
street: string;
exteriorNumber?: string;
interiorNumber?: string;
neighborhood?: string;
city: string;
municipality?: string;
state: string;
postalCode: string;
country?: string;
reference?: string;
latitude?: number;
longitude?: number;
}
export interface UpdatePartnerAddressDto {
addressType?: AddressType;
isDefault?: boolean;
label?: string;
street?: string;
exteriorNumber?: string;
interiorNumber?: string;
neighborhood?: string;
city?: string;
municipality?: string;
state?: string;
postalCode?: string;
country?: string;
reference?: string;
latitude?: number;
longitude?: number;
}
// ==================== Partner Contact Types ====================
export interface PartnerContact {
id: string;
partnerId: string;
fullName: string;
position?: string | null;
department?: string | null;
email?: string | null;
phone?: string | null;
mobile?: string | null;
extension?: string | null;
isPrimary: boolean;
isBillingContact: boolean;
isShippingContact: boolean;
receivesNotifications: boolean;
notes?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreatePartnerContactDto {
fullName: string;
position?: string;
department?: string;
email?: string;
phone?: string;
mobile?: string;
extension?: string;
isPrimary?: boolean;
isBillingContact?: boolean;
isShippingContact?: boolean;
receivesNotifications?: boolean;
notes?: string;
}
export interface UpdatePartnerContactDto {
fullName?: string;
position?: string;
department?: string;
email?: string;
phone?: string;
mobile?: string;
extension?: string;
isPrimary?: boolean;
isBillingContact?: boolean;
isShippingContact?: boolean;
receivesNotifications?: boolean;
notes?: string;
}
// ==================== Partner Bank Account Types ====================
export type BankAccountType = 'checking' | 'savings';
export interface PartnerBankAccount {
id: string;
partnerId: string;
bankName: string;
bankCode?: string | null;
accountNumber: string;
clabe?: string | null;
accountType: BankAccountType;
currency: string;
beneficiaryName?: string | null;
beneficiaryTaxId?: string | null;
swiftCode?: string | null;
isDefault: boolean;
isVerified: boolean;
verifiedAt?: string | null;
notes?: string | null;
createdAt: string;
updatedAt?: string | null;
}
export interface CreatePartnerBankAccountDto {
bankName: string;
bankCode?: string;
accountNumber: string;
clabe?: string;
accountType?: BankAccountType;
currency?: string;
beneficiaryName?: string;
beneficiaryTaxId?: string;
swiftCode?: string;
isDefault?: boolean;
notes?: string;
}
export interface UpdatePartnerBankAccountDto {
bankName?: string;
bankCode?: string;
accountNumber?: string;
clabe?: string;
accountType?: BankAccountType;
currency?: string;
beneficiaryName?: string;
beneficiaryTaxId?: string;
swiftCode?: string;
isDefault?: boolean;
notes?: string;
}