feat: multi-tenant dashboard shell -- auth, onboarding, organizations, tasks, team
- login page (Supabase email/password sign-in + sign-up) - onboarding: create first tenant, redirects into /dashboard once a membership exists - dashboard shell: sidebar nav, tenant switcher (x-tenant-id header, never in body/query -- matches ceo-api's TenantGuard), sign out - Organizations, Tasks, and Team (members/RBAC) pages wired to ceo-api via a thin apiFetch wrapper that attaches the Supabase bearer token - executive "paper & ink" design tokens (Tailwind) replacing defaults - Dockerfile: declare NEXT_PUBLIC_* as build ARGs so Coolify's existing build-time env vars actually get inlined (was previously silently dropped, which would have crashed prerendering)
This commit is contained in:
parent
349f4f713c
commit
883ff370cd
18 changed files with 1076 additions and 10 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -2,3 +2,4 @@ node_modules/
|
|||
.next/
|
||||
.env
|
||||
*.log
|
||||
next-env.d.ts
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@ WORKDIR /app
|
|||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --legacy-peer-deps --include=dev
|
||||
COPY . .
|
||||
# NEXT_PUBLIC_* sunt inlinite in bundle la build time, nu citite la runtime;
|
||||
# Coolify le are configurate ca build-time variables (is_buildtime=true) si le
|
||||
# injecteaza automat ca --build-arg pentru un Dockerfile care le declara ARG.
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
|
|
|||
8
src/app/dashboard/layout.tsx
Normal file
8
src/app/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { DashboardShell } from '../../components/dashboard-shell';
|
||||
|
||||
// Tot ce e sub /dashboard depinde de sesiunea/tenantul curent -- niciodata prerandat static.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardShell>{children}</DashboardShell>;
|
||||
}
|
||||
126
src/app/dashboard/members/page.tsx
Normal file
126
src/app/dashboard/members/page.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, type TenantMember } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
const addMemberSchema = z.object({
|
||||
email: z.string().email('Adresă de email invalidă'),
|
||||
role: z.enum(['admin', 'member']),
|
||||
});
|
||||
type AddMemberForm = z.infer<typeof addMemberSchema>;
|
||||
|
||||
export default function MembersPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const canManage = activeTenant?.role === 'owner' || activeTenant?.role === 'admin';
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
const { data: members = [], isLoading } = useQuery({
|
||||
queryKey: ['tenant-members', tenantId],
|
||||
queryFn: () => apiFetch<TenantMember[]>('/v1/tenants/current/members', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<AddMemberForm>({ resolver: zodResolver(addMemberSchema), defaultValues: { role: 'member' } });
|
||||
|
||||
const addMember = useMutation({
|
||||
mutationFn: (values: AddMemberForm) =>
|
||||
apiFetch<TenantMember>('/v1/tenants/current/members', { method: 'POST', tenantId, body: values }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tenant-members', tenantId] });
|
||||
reset();
|
||||
setIsFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const removeMember = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
apiFetch<{ removed: boolean }>(`/v1/tenants/current/members/${userId}`, { method: 'DELETE', tenantId }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tenant-members', tenantId] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Echipă</h1>
|
||||
<p className="text-sm text-ink-faint">Cine are acces la acest workspace.</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
|
||||
{isFormOpen ? 'Anulează' : 'Invită membru'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFormOpen && (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => addMember.mutate(values))}
|
||||
noValidate
|
||||
className="card mb-6 space-y-4 p-5"
|
||||
>
|
||||
<div>
|
||||
<label className="label" htmlFor="member-email">
|
||||
Email
|
||||
</label>
|
||||
<input id="member-email" type="email" className="field" {...register('email')} />
|
||||
{errors.email && <p className="mt-1 text-xs text-signal-danger">{errors.email.message}</p>}
|
||||
<p className="mt-1 text-xs text-ink-faint">Persoana trebuie să aibă deja un cont creat.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="member-role">
|
||||
Rol
|
||||
</label>
|
||||
<select id="member-role" className="field" {...register('role')}>
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
{addMember.isError && (
|
||||
<p className="text-sm text-signal-danger">
|
||||
{addMember.error instanceof Error ? addMember.error.message : 'Eroare'}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={isSubmitting || addMember.isPending} className="btn-primary">
|
||||
Adaugă
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-ink-faint">Se încarcă…</p>}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{members.map((member) => (
|
||||
<li key={member.userId} className="card flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-ink">{member.email ?? member.userId}</p>
|
||||
<p className="text-xs capitalize text-ink-faint">{member.role}</p>
|
||||
</div>
|
||||
{canManage && member.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMember.mutate(member.userId)}
|
||||
className="text-xs font-medium text-signal-danger hover:underline"
|
||||
>
|
||||
Elimină
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/app/dashboard/organizations/page.tsx
Normal file
114
src/app/dashboard/organizations/page.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, type Organization } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
const createOrganizationSchema = z.object({
|
||||
name: z.string().min(1, 'Numele este obligatoriu').max(250),
|
||||
domain: z.string().max(253).optional().or(z.literal('')),
|
||||
});
|
||||
type CreateOrganizationForm = z.infer<typeof createOrganizationSchema>;
|
||||
|
||||
export default function OrganizationsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
const { data: organizations = [], isLoading } = useQuery({
|
||||
queryKey: ['organizations', tenantId],
|
||||
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateOrganizationForm>({ resolver: zodResolver(createOrganizationSchema) });
|
||||
|
||||
const createOrganization = useMutation({
|
||||
mutationFn: (values: CreateOrganizationForm) =>
|
||||
apiFetch<Organization>('/v1/organizations', {
|
||||
method: 'POST',
|
||||
tenantId,
|
||||
body: { name: values.name, domain: values.domain || undefined },
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['organizations', tenantId] });
|
||||
reset();
|
||||
setIsFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Companii</h1>
|
||||
<p className="text-sm text-ink-faint">Companiile pe care le gestionezi în acest workspace.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
|
||||
{isFormOpen ? 'Anulează' : 'Adaugă companie'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFormOpen && (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => createOrganization.mutate(values))}
|
||||
noValidate
|
||||
className="card mb-6 space-y-4 p-5"
|
||||
>
|
||||
<div>
|
||||
<label className="label" htmlFor="org-name">
|
||||
Nume
|
||||
</label>
|
||||
<input id="org-name" type="text" className="field" {...register('name')} />
|
||||
{errors.name && <p className="mt-1 text-xs text-signal-danger">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="org-domain">
|
||||
Domeniu (opțional)
|
||||
</label>
|
||||
<input id="org-domain" type="text" placeholder="exemplu.ro" className="field" {...register('domain')} />
|
||||
</div>
|
||||
{createOrganization.isError && (
|
||||
<p className="text-sm text-signal-danger">
|
||||
{createOrganization.error instanceof Error ? createOrganization.error.message : 'Eroare'}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={isSubmitting || createOrganization.isPending} className="btn-primary">
|
||||
Salvează
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-ink-faint">Se încarcă…</p>}
|
||||
{!isLoading && organizations.length === 0 && (
|
||||
<p className="card p-6 text-sm text-ink-faint">Nicio companie încă. Adaugă prima mai sus.</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{organizations.map((organization) => (
|
||||
<li key={organization.id} className="card flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-ink">{organization.name}</p>
|
||||
{organization.domain && <p className="text-xs text-ink-faint">{organization.domain}</p>}
|
||||
</div>
|
||||
{organization.country && (
|
||||
<span className="rounded-full bg-paper-sunken px-2.5 py-1 text-xs text-ink-soft">
|
||||
{organization.country}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/app/dashboard/page.tsx
Normal file
43
src/app/dashboard/page.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { apiFetch, type Organization, type Task } from '../../lib/api';
|
||||
import { useSession } from '../../components/session-provider';
|
||||
|
||||
export default function OverviewPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
|
||||
const { data: organizations = [] } = useQuery({
|
||||
queryKey: ['organizations', tenantId],
|
||||
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const { data: openTasks = [] } = useQuery({
|
||||
queryKey: ['tasks', tenantId, 'open'],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?status=open', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">
|
||||
Bine ai revenit{activeTenant ? `, ${activeTenant.tenantName}` : ''}
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-ink-faint">Prezentarea generală a workspace-ului activ.</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link href="/dashboard/organizations" className="card block p-5 transition-shadow hover:shadow-overlay">
|
||||
<p className="label">Companii</p>
|
||||
<p className="font-display text-3xl font-semibold text-ink">{organizations.length}</p>
|
||||
</Link>
|
||||
<Link href="/dashboard/tasks" className="card block p-5 transition-shadow hover:shadow-overlay">
|
||||
<p className="label">Sarcini deschise</p>
|
||||
<p className="font-display text-3xl font-semibold text-ink">{openTasks.length}</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/app/dashboard/tasks/page.tsx
Normal file
131
src/app/dashboard/tasks/page.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, type Task, type TaskStatus } from '../../../lib/api';
|
||||
import { useSession } from '../../../components/session-provider';
|
||||
|
||||
const createTaskSchema = z.object({
|
||||
title: z.string().min(1, 'Titlul este obligatoriu').max(500),
|
||||
});
|
||||
type CreateTaskForm = z.infer<typeof createTaskSchema>;
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
open: 'Deschis',
|
||||
in_progress: 'În lucru',
|
||||
blocked: 'Blocat',
|
||||
done: 'Finalizat',
|
||||
cancelled: 'Anulat',
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['tasks', tenantId, 'all'],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks', { tenantId }),
|
||||
enabled: Boolean(tenantId),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateTaskForm>({ resolver: zodResolver(createTaskSchema) });
|
||||
|
||||
const createTask = useMutation({
|
||||
mutationFn: (values: CreateTaskForm) =>
|
||||
apiFetch<Task>('/v1/tasks', { method: 'POST', tenantId, body: values }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tasks', tenantId] });
|
||||
reset();
|
||||
setIsFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updateStatus = useMutation({
|
||||
mutationFn: ({ id, status }: { id: string; status: TaskStatus }) =>
|
||||
apiFetch<Task>(`/v1/tasks/${id}`, { method: 'PATCH', tenantId, body: { status } }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['tasks', tenantId] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Sarcini</h1>
|
||||
<p className="text-sm text-ink-faint">Sarcinile tale și termenele asociate.</p>
|
||||
</div>
|
||||
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((open) => !open)}>
|
||||
{isFormOpen ? 'Anulează' : 'Sarcină nouă'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isFormOpen && (
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => createTask.mutate(values))}
|
||||
noValidate
|
||||
className="card mb-6 space-y-4 p-5"
|
||||
>
|
||||
<div>
|
||||
<label className="label" htmlFor="task-title">
|
||||
Titlu
|
||||
</label>
|
||||
<input id="task-title" type="text" className="field" {...register('title')} />
|
||||
{errors.title && <p className="mt-1 text-xs text-signal-danger">{errors.title.message}</p>}
|
||||
</div>
|
||||
{createTask.isError && (
|
||||
<p className="text-sm text-signal-danger">
|
||||
{createTask.error instanceof Error ? createTask.error.message : 'Eroare'}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={isSubmitting || createTask.isPending} className="btn-primary">
|
||||
Salvează
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-ink-faint">Se încarcă…</p>}
|
||||
{!isLoading && tasks.length === 0 && (
|
||||
<p className="card p-6 text-sm text-ink-faint">Nicio sarcină încă. Adaugă prima mai sus.</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id} className="card flex items-center justify-between p-4">
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${task.status === 'done' ? 'text-ink-faint line-through' : 'text-ink'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.dueAt && <p className="text-xs text-ink-faint">Termen: {new Date(task.dueAt).toLocaleDateString('ro-RO')}</p>}
|
||||
</div>
|
||||
<select
|
||||
aria-label={`Status pentru ${task.title}`}
|
||||
className="rounded-lg border border-ink-line bg-paper-raised px-2.5 py-1.5 text-xs font-medium text-ink-soft
|
||||
focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20"
|
||||
value={task.status}
|
||||
onChange={(event) =>
|
||||
updateStatus.mutate({ id: task.id, status: event.target.value as TaskStatus })
|
||||
}
|
||||
>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,35 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-paper text-ink antialiased;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-bronze-wash text-bronze-deep;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg bg-ink px-4 py-2.5 text-sm font-medium text-paper
|
||||
transition-colors hover:bg-ink-soft focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2
|
||||
focus-visible:outline-bronze disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-ink-line bg-paper-raised px-4 py-2.5
|
||||
text-sm font-medium text-ink-soft transition-colors hover:border-ink-faint hover:text-ink
|
||||
focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-bronze;
|
||||
}
|
||||
.field {
|
||||
@apply w-full rounded-lg border border-ink-line bg-paper-raised px-3.5 py-2.5 text-sm text-ink
|
||||
placeholder:text-ink-faint focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-xl border border-ink-line bg-paper-raised shadow-card;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1.5 block text-xs font-medium uppercase tracking-wide text-ink-faint;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,24 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Fraunces, Inter } from 'next/font/google';
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
const display = Fraunces({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
weight: ['500', '600'],
|
||||
});
|
||||
const sans = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CEO OS',
|
||||
description: 'Sistem complex pentru antreprenori',
|
||||
description: 'Personal & Executive Intelligence Operating System',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ro">
|
||||
<body>
|
||||
<html lang="ro" className={`${display.variable} ${sans.variable}`}>
|
||||
<body className="font-sans">
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
128
src/app/login/page.tsx
Normal file
128
src/app/login/page.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email('Adresă de email invalidă'),
|
||||
password: z.string().min(8, 'Minimum 8 caractere'),
|
||||
});
|
||||
type Credentials = z.infer<typeof credentialsSchema>;
|
||||
|
||||
type Mode = 'sign-in' | 'sign-up';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [mode, setMode] = useState<Mode>('sign-in');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [confirmationSent, setConfirmationSent] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<Credentials>({ resolver: zodResolver(credentialsSchema) });
|
||||
|
||||
const onSubmit = async (values: Credentials) => {
|
||||
setFormError(null);
|
||||
setConfirmationSent(false);
|
||||
|
||||
if (mode === 'sign-up') {
|
||||
const { error, data } = await supabase.auth.signUp({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
if (error) {
|
||||
setFormError(error.message);
|
||||
return;
|
||||
}
|
||||
if (!data.session) {
|
||||
setConfirmationSent(true);
|
||||
return;
|
||||
}
|
||||
router.push('/onboarding');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword(values);
|
||||
if (error) {
|
||||
setFormError(error.message);
|
||||
return;
|
||||
}
|
||||
router.push('/dashboard');
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-paper px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">CEO OS</h1>
|
||||
<p className="mb-8 text-sm text-ink-faint">
|
||||
{mode === 'sign-in' ? 'Autentifică-te în contul tău' : 'Creează un cont nou'}
|
||||
</p>
|
||||
|
||||
<div className="card p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div>
|
||||
<label className="label" htmlFor="email">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className="field"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-signal-danger">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="password">
|
||||
Parolă
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={mode === 'sign-in' ? 'current-password' : 'new-password'}
|
||||
className="field"
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-xs text-signal-danger">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formError && <p className="text-sm text-signal-danger">{formError}</p>}
|
||||
{confirmationSent && (
|
||||
<p className="text-sm text-signal-ok">
|
||||
Cont creat. Verifică emailul pentru confirmare, apoi autentifică-te.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting} className="btn-primary w-full">
|
||||
{mode === 'sign-in' ? 'Autentificare' : 'Creează cont'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in');
|
||||
setFormError(null);
|
||||
setConfirmationSent(false);
|
||||
}}
|
||||
className="mt-4 w-full text-center text-sm text-ink-faint hover:text-ink"
|
||||
>
|
||||
{mode === 'sign-in' ? 'Nu ai cont? Creează unul' : 'Ai deja cont? Autentifică-te'}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
94
src/app/onboarding/page.tsx
Normal file
94
src/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, ACTIVE_TENANT_STORAGE_KEY, type TenantMembership } from '../../lib/api';
|
||||
import { useSession } from '../../components/session-provider';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const createTenantSchema = z.object({
|
||||
name: z.string().min(2, 'Minimum 2 caractere').max(120),
|
||||
});
|
||||
type CreateTenantForm = z.infer<typeof createTenantSchema>;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { authSession, authLoading, me, meLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!authSession) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (!meLoading && me && me.tenants.length > 0) {
|
||||
router.replace('/dashboard');
|
||||
}
|
||||
}, [authLoading, authSession, me, meLoading, router]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateTenantForm>({ resolver: zodResolver(createTenantSchema) });
|
||||
|
||||
const createTenant = useMutation({
|
||||
mutationFn: (values: CreateTenantForm) =>
|
||||
apiFetch<TenantMembership>('/v1/tenants', { method: 'POST', body: values }),
|
||||
onSuccess: async (tenant) => {
|
||||
window.localStorage.setItem(ACTIVE_TENANT_STORAGE_KEY, tenant.tenantId);
|
||||
await queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||
router.replace('/dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-paper px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<h1 className="mb-1 font-display text-2xl font-semibold text-ink">Bine ai venit</h1>
|
||||
<p className="mb-8 text-sm text-ink-faint">
|
||||
Dă un nume companiei sau spațiului tău de lucru. Îl poți schimba oricând.
|
||||
</p>
|
||||
|
||||
<div className="card p-6">
|
||||
<form
|
||||
onSubmit={handleSubmit((values) => createTenant.mutate(values))}
|
||||
noValidate
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="label" htmlFor="name">
|
||||
Numele companiei / workspace-ului
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="ex. Rotaru Consulting SRL"
|
||||
className="field"
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-xs text-signal-danger">{errors.name.message}</p>}
|
||||
</div>
|
||||
|
||||
{createTenant.isError && (
|
||||
<p className="text-sm text-signal-danger">
|
||||
{createTenant.error instanceof Error ? createTenant.error.message : 'A apărut o eroare'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={isSubmitting || createTenant.isPending} className="btn-primary w-full">
|
||||
Continuă
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,34 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useSession } from '../components/session-provider';
|
||||
|
||||
// Depinde de sesiunea auth curenta -- niciodata prerandat static/cache-uit.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { authSession, authLoading, me, meLoading } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || (authSession && meLoading)) {
|
||||
return;
|
||||
}
|
||||
if (!authSession) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (me && me.tenants.length === 0) {
|
||||
router.replace('/onboarding');
|
||||
return;
|
||||
}
|
||||
router.replace('/dashboard');
|
||||
}, [authLoading, authSession, me, meLoading, router]);
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto p-8">
|
||||
<h1 className="text-3xl font-bold">CEO OS</h1>
|
||||
<p className="text-gray-500 mt-2">
|
||||
Scaffold initial — conectat la ceo-api si Supabase, stilizat cu Tailwind.
|
||||
</p>
|
||||
<main className="flex min-h-screen items-center justify-center bg-paper">
|
||||
<p className="text-sm text-ink-faint">Se încarcă…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,13 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { SessionProvider } from '../components/session-provider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
86
src/components/dashboard-shell.tsx
Normal file
86
src/components/dashboard-shell.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useSession } from './session-provider';
|
||||
import { TenantSwitcher } from './tenant-switcher';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/dashboard', label: 'Prezentare generală' },
|
||||
{ href: '/dashboard/organizations', label: 'Companii' },
|
||||
{ href: '/dashboard/tasks', label: 'Sarcini' },
|
||||
{ href: '/dashboard/members', label: 'Echipă' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Guard + shell pentru tot ce e sub /dashboard: redirecteaza catre /login sau
|
||||
* /onboarding daca nu exista sesiune / tenant, altfel afiseaza sidebar +
|
||||
* tenant switcher (blueprint 4: identity, tenancy si roluri sunt vizibile
|
||||
* la nivel de UI, nu doar de API).
|
||||
*/
|
||||
export function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { authSession, authLoading, me, meLoading, activeTenant, signOut } = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!authSession) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
if (!meLoading && me && me.tenants.length === 0) {
|
||||
router.replace('/onboarding');
|
||||
}
|
||||
}, [authLoading, authSession, me, meLoading, router]);
|
||||
|
||||
if (authLoading || meLoading || !activeTenant) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper">
|
||||
<p className="text-sm text-ink-faint">Se încarcă…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-paper">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-ink-line bg-paper-raised p-4">
|
||||
<div className="mb-6 px-2">
|
||||
<span className="font-display text-lg font-semibold text-ink">CEO OS</span>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-1">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isActive = item.href === '/dashboard' ? pathname === item.href : pathname.startsWith(item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-bronze-wash text-bronze-deep' : 'text-ink-soft hover:bg-paper-sunken hover:text-ink'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-ink-line pt-3">
|
||||
<p className="truncate px-2 text-xs text-ink-faint">{me?.email}</p>
|
||||
<button type="button" onClick={() => void signOut()} className="mt-1 w-full px-2 py-1.5 text-left text-sm text-ink-faint hover:text-ink">
|
||||
Deconectare
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-ink-line px-6">
|
||||
<TenantSwitcher />
|
||||
<span className="rounded-full bg-paper-sunken px-2.5 py-1 text-xs font-medium capitalize text-ink-soft">
|
||||
{activeTenant.role}
|
||||
</span>
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/session-provider.tsx
Normal file
85
src/components/session-provider.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Session } from '@supabase/supabase-js';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { ACTIVE_TENANT_STORAGE_KEY, apiFetch, type MeResponse, type TenantMembership } from '../lib/api';
|
||||
|
||||
interface SessionContextValue {
|
||||
authSession: Session | null;
|
||||
authLoading: boolean;
|
||||
me: MeResponse | null;
|
||||
meLoading: boolean;
|
||||
activeTenant: TenantMembership | null;
|
||||
setActiveTenantId: (tenantId: string) => void;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
|
||||
export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [authSession, setAuthSession] = useState<Session | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [activeTenantId, setActiveTenantIdState] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
setAuthSession(data.session);
|
||||
setAuthLoading(false);
|
||||
});
|
||||
const { data: subscription } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setAuthSession(session);
|
||||
});
|
||||
setActiveTenantIdState(window.localStorage.getItem(ACTIVE_TENANT_STORAGE_KEY));
|
||||
return () => subscription.subscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const { data: me = null, isLoading: meLoading } = useQuery({
|
||||
queryKey: ['me', authSession?.user.id],
|
||||
queryFn: () => apiFetch<MeResponse>('/v1/me'),
|
||||
enabled: Boolean(authSession),
|
||||
});
|
||||
|
||||
const activeTenant = useMemo(() => {
|
||||
if (!me || me.tenants.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return me.tenants.find((tenant) => tenant.tenantId === activeTenantId) ?? me.tenants[0];
|
||||
}, [me, activeTenantId]);
|
||||
|
||||
const setActiveTenantId = useCallback(
|
||||
(tenantId: string) => {
|
||||
window.localStorage.setItem(ACTIVE_TENANT_STORAGE_KEY, tenantId);
|
||||
setActiveTenantIdState(tenantId);
|
||||
// datele afisate apartin tenantului anterior — invalidam tot ce e tenant-scoped
|
||||
queryClient.invalidateQueries();
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut();
|
||||
window.localStorage.removeItem(ACTIVE_TENANT_STORAGE_KEY);
|
||||
queryClient.clear();
|
||||
router.push('/login');
|
||||
}, [queryClient, router]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut }),
|
||||
[authSession, authLoading, me, meLoading, activeTenant, setActiveTenantId, signOut],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
|
||||
export function useSession(): SessionContextValue {
|
||||
const context = useContext(SessionContext);
|
||||
if (!context) {
|
||||
throw new Error('useSession must be used inside SessionProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
31
src/components/tenant-switcher.tsx
Normal file
31
src/components/tenant-switcher.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import { useSession } from './session-provider';
|
||||
|
||||
export function TenantSwitcher() {
|
||||
const { me, activeTenant, setActiveTenantId } = useSession();
|
||||
|
||||
if (!me || me.tenants.length === 0 || !activeTenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (me.tenants.length === 1) {
|
||||
return <span className="text-sm font-medium text-ink">{activeTenant.tenantName}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<select
|
||||
aria-label="Workspace activ"
|
||||
className="rounded-lg border border-ink-line bg-paper-raised px-3 py-1.5 text-sm font-medium text-ink
|
||||
focus:border-bronze focus:outline-none focus:ring-2 focus:ring-bronze/20"
|
||||
value={activeTenant.tenantId}
|
||||
onChange={(event) => setActiveTenantId(event.target.value)}
|
||||
>
|
||||
{me.tenants.map((tenant) => (
|
||||
<option key={tenant.tenantId} value={tenant.tenantId}>
|
||||
{tenant.tenantName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
105
src/lib/api.ts
Normal file
105
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { supabase } from './supabase';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001';
|
||||
|
||||
export const ACTIVE_TENANT_STORAGE_KEY = 'ceo-os.active-tenant';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiFetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
/** Rutele de bootstrap (/v1/me, /v1/tenants) nu trimit tenant header. */
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toate apelurile catre ceo-api trec pe aici: Bearer din sesiunea Supabase,
|
||||
* tenantul activ in x-tenant-id (niciodata in body/query — TenantGuard pe
|
||||
* server respinge exact acel pattern).
|
||||
*/
|
||||
export async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise<T> {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
const token = data.session?.access_token;
|
||||
if (!token) {
|
||||
throw new ApiError(401, 'Not signed in');
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (options.tenantId) {
|
||||
headers['x-tenant-id'] = options.tenantId;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed (${response.status})`;
|
||||
try {
|
||||
const payload = (await response.json()) as { message?: string | string[] };
|
||||
if (payload.message) {
|
||||
message = Array.isArray(payload.message) ? payload.message.join('; ') : payload.message;
|
||||
}
|
||||
} catch {
|
||||
// corpul nu e JSON — pastram mesajul generic
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
// --- Tipuri aliniate la contractele ceo-api ---
|
||||
|
||||
export interface TenantMembership {
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
tenants: TenantMembership[];
|
||||
}
|
||||
|
||||
export interface Organization {
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
country: string | null;
|
||||
registryId: string | null;
|
||||
domain: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type TaskStatus = 'open' | 'in_progress' | 'blocked' | 'done' | 'cancelled';
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: number;
|
||||
dueAt: string | null;
|
||||
status: TaskStatus;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TenantMember {
|
||||
userId: string;
|
||||
email: string | null;
|
||||
role: 'owner' | 'admin' | 'member';
|
||||
createdAt: string;
|
||||
}
|
||||
|
|
@ -1,7 +1,43 @@
|
|||
import type { Config } from 'tailwindcss';
|
||||
|
||||
// Design tokens CEO OS — "executive paper & ink": suprafete calde, cerneala
|
||||
// densa, un singur accent (bronz) folosit semantic pentru actiuni primare.
|
||||
export default {
|
||||
content: ['./src/**/*.{ts,tsx}'],
|
||||
theme: { extend: {} },
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
paper: {
|
||||
DEFAULT: '#faf8f4',
|
||||
raised: '#ffffff',
|
||||
sunken: '#f1ede5',
|
||||
},
|
||||
ink: {
|
||||
DEFAULT: '#191714',
|
||||
soft: '#4a453d',
|
||||
faint: '#8a8375',
|
||||
line: '#e4ded2',
|
||||
},
|
||||
bronze: {
|
||||
DEFAULT: '#9a6b2f',
|
||||
deep: '#7c5322',
|
||||
wash: '#f3e9da',
|
||||
},
|
||||
signal: {
|
||||
ok: '#3d7a4e',
|
||||
warn: '#b07a1e',
|
||||
danger: '#a63d2f',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['var(--font-display)', 'Georgia', 'serif'],
|
||||
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
card: '0 1px 2px rgba(25,23,20,0.05), 0 4px 16px rgba(25,23,20,0.06)',
|
||||
overlay: '0 8px 40px rgba(25,23,20,0.18)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
Loading…
Reference in a new issue