Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import type { Conversation, Contact, Tag } from "@/types";
/**
* Conversation select that embeds the contact plus its tags, so the Inbox
* can filter conversations by contact tag without a second round-trip.
* `contact_tags(tags(*))` returns the join rows; {@link normalizeConversation}
* flattens them onto `contact.tags`.
*/
export const CONVERSATION_SELECT =
"*, contact:contacts(*, contact_tags(tags(*)))";
/** Raw shape returned by {@link CONVERSATION_SELECT} before flattening. */
type RawContact = Contact & { contact_tags?: { tags: Tag | null }[] };
type RawConversation = Omit<Conversation, "contact"> & {
contact?: RawContact | null;
};
/**
* Flatten the embedded `contact_tags(tags(*))` join into `contact.tags`.
* Safe to call on rows fetched with {@link CONVERSATION_SELECT}; a row with
* no contact (e.g. a freshly-inserted conversation) passes through untouched.
*/
export function normalizeConversation(raw: RawConversation): Conversation {
const rawContact = raw.contact;
if (!rawContact) return raw as Conversation;
const { contact_tags, ...contact } = rawContact;
return {
...raw,
contact: {
...contact,
tags: (contact_tags ?? [])
.map((ct) => ct.tags)
.filter((t): t is Tag => t != null),
},
};
}
export function normalizeConversations(
rows: RawConversation[],
): Conversation[] {
return rows.map(normalizeConversation);
}
export interface ContactFilters {
/** Tag ids; a conversation matches if its contact has ANY of them (OR). */
tagIds: string[];
/** Exact company match, or null for no company filter. */
company: string | null;
}
/**
* Whether a conversation passes the contact-based Inbox filters (issue #272).
* Empty `tagIds` and null `company` are no-ops, so the default (no filters)
* always matches. Tags use OR logic, consistent with Broadcast audiences.
*/
export function matchesContactFilters(
conversation: Conversation,
{ tagIds, company }: ContactFilters,
): boolean {
if (tagIds.length > 0) {
const contactTagIds = conversation.contact?.tags ?? [];
if (!contactTagIds.some((t) => tagIds.includes(t.id))) return false;
}
if (company !== null && conversation.contact?.company?.trim() !== company) {
return false;
}
return true;
}