From 8ca55eac5bf7f38960ab8165ad6b4d089384e446 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:23:15 +0000 Subject: [PATCH 1/4] Show tenant logo in header/sidebar when uploaded Adds a BrandLogo component that swaps in the tenant's uploaded logo (settings.logoUrl), resized to the same height as the default mark, and falls back to the ArchiOffice SVG mark otherwise. --- src/App.tsx | 8 +++++--- src/components/ArchiOfficeLogo.tsx | 21 +++++++++++++++++++++ src/components/Sidebar.tsx | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e1f7d5f..3d1504f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import { IconMessageCircle, IconUser, } from '@tabler/icons-react'; -import { ArchiOfficeLogo } from './components/ArchiOfficeLogo'; +import { BrandLogo } from './components/ArchiOfficeLogo'; import { UpdateBanner } from './components/UpdateBanner'; import { useState, useEffect, useRef, lazy, Suspense } from 'react'; import { motion, AnimatePresence } from 'motion/react'; @@ -25,6 +25,7 @@ import { Sidebar, NAV_ITEMS } from './components/Sidebar'; import { apiFetch } from './lib/api'; import { isOfflineBuild } from './lib/authToken'; import { getSyncStatus, triggerSyncNow, SyncStatusResponse } from './lib/cloudSync'; +import { useSettings } from './hooks/useSettings'; // Pages — lazy-loaded so each route's JS is only fetched when it's visited, // instead of every page (ProjectDetail alone is ~270KB) landing in one @@ -164,6 +165,7 @@ function Header() { const { t } = useTranslation(); const { theme, setTheme } = useTheme(); const { currentUser, headerTitle, setHeaderTitle, signOut } = useUser(); + const { settings } = useSettings(); const location = useLocation(); const navigate = useNavigate(); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); @@ -296,7 +298,7 @@ function Header() { className="md:hidden flex items-center gap-2 font-bold text-sm" style={{ color: 'var(--tblr-text)' }} > - + ArchiOffice

- + ArchiOffice {/* Nav items */} diff --git a/src/components/ArchiOfficeLogo.tsx b/src/components/ArchiOfficeLogo.tsx index a558496..608189a 100644 --- a/src/components/ArchiOfficeLogo.tsx +++ b/src/components/ArchiOfficeLogo.tsx @@ -30,3 +30,24 @@ export function ArchiOfficeLogo({ className, size = 32 }: ArchiOfficeLogoProps) ); } + +interface BrandLogoProps { + logoUrl?: string | null; + className?: string; + size?: number; +} + +/** Tenant logo when one has been uploaded, resized to the same height as the default mark; falls back to the ArchiOffice mark otherwise. */ +export function BrandLogo({ logoUrl, className, size = 32 }: BrandLogoProps) { + if (logoUrl) { + return ( + Logo + ); + } + return ; +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6d479f6..ded0d8d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -29,7 +29,7 @@ import { IconCloudUpload, IconBuildingBank, } from '@tabler/icons-react'; -import { ArchiOfficeLogo } from './ArchiOfficeLogo'; +import { BrandLogo } from './ArchiOfficeLogo'; import { useUser } from '../UserContext'; import { useSettings } from '../hooks/useSettings'; import { apiFetch } from '../lib/api'; @@ -172,7 +172,7 @@ export function Sidebar() { className="flex items-center gap-2 font-bold text-base tracking-tight" style={{ color: 'var(--tblr-text)' }} > - + ArchiOffice {settings?.agencyName && ( From 928e72edb143fb82ce463ecd2dee93e3c96a1aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 07:37:01 +0000 Subject: [PATCH 2/4] Let agents create contacts and proposals via Gemini function calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents were read-only: buildAgentContext only ever fetched data, and the chat handler made a single sendMessage call with no tools, so an agent literally could not write anything back to the cabinet even when asked to (e.g. adding a new client and drafting their devis). Adds a new agents.action_scopes column (contacts_write, proposals_write), separate from the existing read-only context_scopes, that the architect toggles per agent in AgentConfig. When granted, the chat route registers matching Gemini function declarations (create_contact, create_proposal) and loops sendMessage calls to execute chained tool calls — e.g. create the contact first, then the proposal against the returned client_id — before returning the final reply, with a confirmation summary of what was actually created. --- .../src/client/AgentConfig.tsx | 36 +++- .../archioffice-agents/src/server/routes.ts | 69 +++++-- .../src/server/systemPrompts.ts | 13 +- .../archioffice-agents/src/server/tools.ts | 171 ++++++++++++++++++ packages/archioffice-agents/src/types.ts | 6 + server.ts | 3 + src/locales/fr.ts | 2 + supabase/migrate_add_agent_actions.sql | 9 + 8 files changed, 295 insertions(+), 14 deletions(-) create mode 100644 packages/archioffice-agents/src/server/tools.ts create mode 100644 supabase/migrate_add_agent_actions.sql diff --git a/packages/archioffice-agents/src/client/AgentConfig.tsx b/packages/archioffice-agents/src/client/AgentConfig.tsx index eb2925a..cbfab22 100644 --- a/packages/archioffice-agents/src/client/AgentConfig.tsx +++ b/packages/archioffice-agents/src/client/AgentConfig.tsx @@ -3,7 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { IconArrowLeft, IconRobot, IconChevronDown } from '@tabler/icons-react'; import { apiFetch } from '@/src/lib/api'; -import type { Agent, AgentContextScope } from '../types.js'; +import type { Agent, AgentContextScope, AgentActionScope } from '../types.js'; const AVATAR_COLORS = ['#206bc4', '#2fb344', '#f76707', '#ae3ec9', '#1098ad', '#e8590c']; @@ -15,6 +15,11 @@ const SCOPES: { key: AgentContextScope; label: string }[] = [ { key: 'tasks', label: 'Tâches' }, ]; +const ACTION_SCOPES: { key: AgentActionScope; label: string }[] = [ + { key: 'contacts_write', label: 'Créer des contacts' }, + { key: 'proposals_write', label: 'Créer des devis' }, +]; + export default function AgentConfig() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -33,6 +38,7 @@ export default function AgentConfig() { const [tone, setTone] = useState(''); const [directives, setDirectives] = useState(''); const [contextScopes, setContextScopes] = useState([]); + const [actionScopes, setActionScopes] = useState([]); const [systemPromptOverride, setSystemPromptOverride] = useState(''); useEffect(() => { @@ -48,6 +54,7 @@ export default function AgentConfig() { setTone(found.tone ?? ''); setDirectives(found.directives ?? ''); setContextScopes(found.context_scopes as AgentContextScope[]); + setActionScopes((found.action_scopes ?? []) as AgentActionScope[]); setSystemPromptOverride(found.system_prompt_override ?? ''); }) .finally(() => setLoading(false)); @@ -59,6 +66,12 @@ export default function AgentConfig() { ); }; + const toggleActionScope = (scope: AgentActionScope) => { + setActionScopes((prev: AgentActionScope[]) => + prev.includes(scope) ? prev.filter((s: AgentActionScope) => s !== scope) : [...prev, scope] + ); + }; + const handleSave = async () => { if (!agent) return; setSaving(true); @@ -69,6 +82,7 @@ export default function AgentConfig() { name, role_title: roleTitle, avatar_initials: avatarInitials, avatar_color: avatarColor, tone, directives, context_scopes: contextScopes, + action_scopes: actionScopes, system_prompt_override: systemPromptOverride || null, }), }); @@ -191,6 +205,26 @@ export default function AgentConfig() { + {/* Actions — write permissions */} +
+

{t('agent_config_actions')}

+

{t('agent_config_actions_hint')}

+
+ {ACTION_SCOPES.map(scope => ( + + ))} +
+
+ {/* Advanced prompt */}
+

{t('agent_config_actions_hint')}

- {ACTION_SCOPES.map(scope => ( + {AGENT_RESOURCES.map(scope => (
+ {error && ( +
+ + {error} +
+ )}
{/* Identité Section */}
diff --git a/src/pages/Contacts.tsx b/src/pages/Contacts.tsx index 8f39765..c72a98d 100644 --- a/src/pages/Contacts.tsx +++ b/src/pages/Contacts.tsx @@ -169,30 +169,16 @@ export default function Contacts() { const url = isEditing ? `/api/contacts/${editingId}` : '/api/contacts'; const method = isEditing ? 'PUT' : 'POST'; - const res = await fetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(contact) - }); + await apiFetch(url, { method, body: JSON.stringify(contact) }); - if (res.ok) { - setIsModalOpen(false); - setIsEditing(false); - setEditingId(null); - setNewContact({}); - fetchContacts(); - } else { - const text = await res.text(); - try { - const errorData = JSON.parse(text); - console.error('Failed to save contact:', errorData); - alert(`Failed to save contact: ${errorData.error || 'Unknown error'}`); - } catch (e) { - console.error('Failed to save contact (non-JSON response):', text); - alert(`Failed to save contact: Server returned ${res.status} ${res.statusText}`); - } - } + setIsModalOpen(false); + setIsEditing(false); + setEditingId(null); + setNewContact({}); + fetchContacts(); + showToast(isEditing ? 'Contact modifié' : 'Contact créé'); } catch (err: any) { + console.error('Failed to save contact:', err); showToast(err?.message || 'Erreur lors de la sauvegarde du contact', 'error'); } }; @@ -315,6 +301,7 @@ export default function Contacts() { const handleConfirm = async () => { if (!importData) return; + let failed = 0; for (const row of importData.data) { const contact: Contact = { ...defaultContact, @@ -338,15 +325,20 @@ export default function Contacts() { contact.country = contact.address_work_country || ''; contact.ca_amount = Number(contact.ca_amount) || 0; - await fetch('/api/contacts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(contact) - }); + try { + await apiFetch('/api/contacts', { method: 'POST', body: JSON.stringify(contact) }); + } catch (err) { + console.error('Failed to import contact:', err); + failed++; + } } fetchContacts(); setIsMappingModalOpen(false); setImportData(null); + showToast( + failed > 0 ? `Import terminé avec ${failed} échec(s)` : 'Import terminé', + failed > 0 ? 'error' : 'success' + ); }; if (!isMappingModalOpen || !importData) return null; diff --git a/supabase/migrate_add_team_members_auth_user_id.sql b/supabase/migrate_add_team_members_auth_user_id.sql new file mode 100644 index 0000000..6586e95 --- /dev/null +++ b/supabase/migrate_add_team_members_auth_user_id.sql @@ -0,0 +1,17 @@ +-- ============================================================ +-- ArchiOffice — Migration : team_members.auth_user_id +-- ============================================================ +-- server.ts looks up the current user's team_members row (display name, +-- notifications_last_seen) by their Supabase Auth id. That lookup was +-- filtering on a `user_id` column that was never actually created by any +-- committed migration — team_members.id is the row's own primary key, not +-- the auth id, so every call to getUserName()/the notifications endpoints +-- failed with "column team_members.user_id does not exist" (visible in +-- production logs on every activity-logging call, e.g. right after +-- creating a contact — the insert itself succeeded, but the follow-up +-- getUserName() call then threw, turning the whole request into a 500). +-- This column formalizes the auth-id link so lookups succeed. + +ALTER TABLE team_members ADD COLUMN IF NOT EXISTS auth_user_id TEXT; + +CREATE INDEX IF NOT EXISTS idx_team_members_auth_user ON team_members(auth_user_id, tenant_id);