diff --git a/app/admin/clients/[id]/data/page.tsx b/app/admin/clients/[id]/data/page.tsx new file mode 100644 index 0000000..6f884b3 --- /dev/null +++ b/app/admin/clients/[id]/data/page.tsx @@ -0,0 +1,10 @@ +import StammdatenAdminViewPage from "@/modules/admin/ui/views/stammdaten-admin-view" + +type Params = Promise<{ id: string }>; + +const DataAdminPage = async ({ params }: { params: Params }) => { + const { id } = await params + return +} + +export default DataAdminPage diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 18b7698..9b6c74c 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -2,6 +2,7 @@ import { cookies } from "next/headers"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { verifySession } from "@/lib/supabase/dal"; import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; +import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog"; export default async function AdminLayout({ children }: { children: React.ReactNode }) { @@ -9,6 +10,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN const cookieStore = await cookies() const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false" + // Check if User has accepted the Terms of Services before doing anything + {!session.isAdmin && !session.hasAcceptedTerms && } + return ( diff --git a/app/client/data/page.tsx b/app/client/data/page.tsx new file mode 100644 index 0000000..7abde8a --- /dev/null +++ b/app/client/data/page.tsx @@ -0,0 +1,7 @@ +import StammdatenClientViewPage from "@/modules/client/ui/views/stammdaten-client-view" + +const DataPage = () => { + return +} + +export default DataPage \ No newline at end of file diff --git a/app/client/layout.tsx b/app/client/layout.tsx index 41d181e..dc9b901 100644 --- a/app/client/layout.tsx +++ b/app/client/layout.tsx @@ -2,6 +2,7 @@ import { cookies } from "next/headers"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { verifySession } from "@/lib/supabase/dal"; import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; +import { TermsDialog } from "@/modules/shared/ui/components/terms-dialog"; export default async function ClientLayout({ children }: { children: React.ReactNode }) { @@ -11,6 +12,7 @@ export default async function ClientLayout({ children }: { children: React.React return ( + {!session.isAdmin && !session.hasAcceptedTerms && }
diff --git a/lib/supabase/dal.ts b/lib/supabase/dal.ts index fa10267..1f7c5c2 100644 --- a/lib/supabase/dal.ts +++ b/lib/supabase/dal.ts @@ -18,7 +18,7 @@ export const getProfile = cache(async () => { const supabase = await createClient() const { data } = await supabase .from("profiles") - .select("role, name, email") + .select("role, name, email, terms_accepted_at") .eq("id", user.id) .single() @@ -41,5 +41,6 @@ export const verifySession = cache(async () => { isLoggedIn: true, isAdmin: profile.role === "admin", isClient: profile.role === "client", + hasAcceptedTerms: profile.terms_accepted_at != null, } }) \ No newline at end of file diff --git a/lib/supabase/migrations/20260928092016_create_db_schema.sql b/lib/supabase/migrations/20260928092016_create_db_schema.sql new file mode 100644 index 0000000..352906f --- /dev/null +++ b/lib/supabase/migrations/20260928092016_create_db_schema.sql @@ -0,0 +1,543 @@ +-- ===================================================================== +-- Client-Portal – Datenbankschema (Supabase / PostgreSQL 15+) +-- Voraussetzung: public.profiles existiert bereits +-- (id, email, name, role, created_at; id -> auth.users.id) +-- Falls du bereits einen Trigger auf auth.users hast, VORHER droppen +-- (siehe Abschnitt 3), sonst wird das Profil doppelt angelegt. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 0. Extensions & generische Helfer +-- --------------------------------------------------------------------- +-- Vault wird für verschlüsselte Passwort-Ablage in Abschnitt 5 gebraucht. +create extension if not exists supabase_vault; + +create type public.payment_method as enum ('invoice', 'sepa_debit', 'card'); +create type public.invoice_status as enum ('pending', 'paid', 'cancelled'); -- "fällig" wird abgeleitet (siehe View) +create type public.product_category as enum ('website', 'email', 'seo', 'other'); +create type public.product_status as enum ('online', 'maintenance', 'offline'); +create type public.task_status as enum ('in_progress', 'planned', 'done', 'cancelled'); + +create or replace function public.set_updated_at() +returns trigger language plpgsql set search_path = '' as $$ +begin + new.updated_at = now(); + return new; +end $$; + +-- --------------------------------------------------------------------- +-- 1. Clients (Stammdaten) +-- --------------------------------------------------------------------- +create table public.clients ( + id uuid primary key default gen_random_uuid(), + + -- Firma + company_name text not null, + legal_form text not null, -- GmbH, UG, GbR, e.K., ... + industry text, + vat_id text, -- USt-IdNr + tax_number text, -- Steuernummer + commercial_register_number text, -- optional + logo_path text, -- Pfad im Storage-Bucket, keine öffentliche URL + + -- Firmenadresse + street text not null, + postal_code text not null, + city text not null, + country char(2) not null default 'DE', + + -- abweichende Rechnungsadresse (alles NULL = identisch mit Firmenadresse) + billing_street text, + billing_postal_code text, + billing_city text, + billing_country char(2), + + -- Ansprechpartner + contact_name text not null, + contact_position text, + contact_email text not null, + contact_phone text, + + created_by uuid default auth.uid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint billing_address_all_or_nothing check ( + (billing_street is null and billing_postal_code is null and billing_city is null and billing_country is null) + or + (billing_street is not null and billing_postal_code is not null and billing_city is not null and billing_country is not null) + ) +); + +-- Zahlungsdaten separat: sensibler, eigene Rechte später einfach möglich +create table public.client_payment_details ( + client_id uuid primary key references public.clients(id) on delete cascade, + payment_method public.payment_method not null default 'invoice', + account_holder text, + iban text, + bic text, + sepa_mandate_ref text, + sepa_mandate_date date, + card_customer_id text, -- NUR Token/Customer-ID des Zahlungsanbieters (z. B. Stripe), NIE Kartennummern + updated_at timestamptz not null default now(), + + constraint iban_format check (iban is null or iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'), + constraint sepa_needs_iban check ( + payment_method <> 'sepa_debit' or (iban is not null and account_holder is not null) + ) +); + +-- --------------------------------------------------------------------- +-- 2. profiles erweitern (Login-User -> Client) +-- --------------------------------------------------------------------- +alter table public.profiles + add column if not exists client_id uuid references public.clients(id) on delete set null, + add column if not exists updated_at timestamptz not null default now(); + +-- Admins gehören zu keinem Client +alter table public.profiles + add constraint profiles_admin_has_no_client + check ((role = 'admin' and client_id is null) or role = 'client'); + +-- --------------------------------------------------------------------- +-- 3. Auth-Helfer + Trigger +-- --------------------------------------------------------------------- +create or replace function public.is_admin() +returns boolean language sql stable security definer set search_path = '' as $$ + select exists (select 1 from public.profiles where id = auth.uid() and role = 'admin'); +$$; + +create or replace function public.current_client_id() +returns uuid language sql stable security definer set search_path = '' as $$ + select client_id from public.profiles where id = auth.uid(); +$$; + +-- Gehärteter Trigger: Rolle ist IMMER 'client'. Admins werden manuell per SQL gesetzt. +-- client_id kommt aus raw_app_meta_data (nur per Service-Role/Admin-API setzbar), +-- NICHT aus raw_user_meta_data (die kann ein User beim Signup selbst befüllen). +create or replace function public.handle_new_user() +returns trigger language plpgsql security definer set search_path = '' as $$ +begin + insert into public.profiles (id, email, name, role, client_id) + values ( + new.id, + new.email, + coalesce(new.raw_user_meta_data->>'name', split_part(new.email, '@', 1)), + 'client', + nullif(new.raw_app_meta_data->>'client_id', '')::uuid + ); + return new; +end $$; + +-- drop trigger if exists on auth.users; +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- --------------------------------------------------------------------- +-- 4. Rechnungen +-- --------------------------------------------------------------------- +-- Produkt-Vorlagen für Rechnungspositionen (nur Admin) +create table public.product_templates ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text, + unit_price numeric(12,2) not null check (unit_price >= 0), -- netto + tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100), + is_active boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +-- Lückenloser Nummernkreis pro Jahr (nur über next_invoice_number() nutzbar) +create table public.invoice_counters ( + year int primary key, + last_number int not null default 0 +); +alter table public.invoice_counters enable row level security; -- bewusst keine Policies + +create or replace function public.next_invoice_number() +returns text language plpgsql security definer set search_path = '' as $$ +declare + y int := extract(year from current_date)::int; + n int; +begin + if not public.is_admin() then + raise exception 'forbidden' using errcode = '42501'; + end if; + insert into public.invoice_counters as c (year, last_number) + values (y, 1) + on conflict (year) do update set last_number = c.last_number + 1 + returning c.last_number into n; + return format('RE-%s-%s', y, lpad(n::text, 4, '0')); +end $$; + +create table public.invoices ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete restrict, + invoice_number text not null unique default public.next_invoice_number(), + invoice_date date not null default current_date, + due_date date not null, + status public.invoice_status not null default 'pending', + paid_at date, + + -- Snapshot der Kundendaten zum Rechnungszeitpunkt (werden per Trigger befüllt) + company_name text not null, + vat_id text, + billing_street text not null, + billing_postal_code text not null, + billing_city text not null, + billing_country char(2) not null, + + notes text, + created_by uuid references public.profiles(id) on delete set null default auth.uid(), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint due_after_invoice_date check (due_date >= invoice_date), + constraint paid_has_date check (status <> 'paid' or paid_at is not null) +); + +create or replace function public.fill_invoice_snapshot() +returns trigger language plpgsql set search_path = '' as $$ +declare c public.clients%rowtype; +begin + select * into c from public.clients where id = new.client_id; + new.company_name := coalesce(new.company_name, c.company_name); + new.vat_id := coalesce(new.vat_id, c.vat_id); + new.billing_street := coalesce(new.billing_street, c.billing_street, c.street); + new.billing_postal_code := coalesce(new.billing_postal_code, c.billing_postal_code, c.postal_code); + new.billing_city := coalesce(new.billing_city, c.billing_city, c.city); + new.billing_country := coalesce(new.billing_country, c.billing_country, c.country); + return new; +end $$; + +create trigger fill_invoice_snapshot + before insert on public.invoices + for each row execute function public.fill_invoice_snapshot(); + +create table public.invoice_items ( + id uuid primary key default gen_random_uuid(), + invoice_id uuid not null references public.invoices(id) on delete cascade, + position int not null, + product_template_id uuid references public.product_templates(id) on delete set null, + description text not null, -- Produktname (Snapshot aus Vorlage) + quantity numeric(10,2) not null default 1 check (quantity > 0), + unit_price numeric(12,2) not null, -- netto + tax_rate numeric(5,2) not null default 19.00 check (tax_rate between 0 and 100), + unique (invoice_id, position) +); + +-- Summen + abgeleiteter Status "Fällig" (kein Cronjob nötig) +create view public.invoices_overview with (security_invoker = true) as +select + i.*, + coalesce(t.net_total, 0) as net_total, + coalesce(t.tax_total, 0) as tax_total, + coalesce(t.net_total, 0) + coalesce(t.tax_total, 0) as gross_total, + case when i.status = 'pending' and i.due_date < current_date + then 'overdue' else i.status::text end as display_status +from public.invoices i +left join lateral ( + select sum(round(quantity * unit_price, 2)) as net_total, + sum(round(quantity * unit_price * tax_rate / 100, 2)) as tax_total + from public.invoice_items ii + where ii.invoice_id = i.id +) t on true; + +-- --------------------------------------------------------------------- +-- 5. Passwörter (Supabase Vault = verschlüsselt at rest) +-- In der Tabelle liegt NIE das Passwort, nur die Vault-Referenz. +-- --------------------------------------------------------------------- +create table public.credentials ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + service text not null, -- Vercel, Hostinger, ... + username text not null, -- E-Mail / Username + vault_secret_id uuid not null, -- -> vault.secrets + created_by uuid references public.profiles(id) on delete set null default auth.uid(), + updated_by uuid references public.profiles(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table public.credential_access_log ( + id bigint generated always as identity primary key, + credential_id uuid not null, -- bewusst ohne FK: Log bleibt bei Löschung erhalten + user_id uuid, + action text not null default 'reveal', + created_at timestamptz not null default now() +); + +create or replace function public.create_credential( + p_client_id uuid, p_service text, p_username text, p_password text +) returns uuid language plpgsql security definer set search_path = '' as $$ +declare v_secret uuid; v_id uuid; +begin + if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if; + v_secret := vault.create_secret(p_password, 'cred_' || gen_random_uuid()::text, p_service); + insert into public.credentials (client_id, service, username, vault_secret_id, updated_by) + values (p_client_id, p_service, p_username, v_secret, auth.uid()) + returning id into v_id; + return v_id; +end $$; + +-- p_password = NULL -> Passwort bleibt unverändert +create or replace function public.update_credential( + p_id uuid, p_service text, p_username text, p_password text default null +) returns void language plpgsql security definer set search_path = '' as $$ +declare v_secret uuid; +begin + if not public.is_admin() then raise exception 'forbidden' using errcode = '42501'; end if; + update public.credentials + set service = p_service, username = p_username, updated_by = auth.uid() + where id = p_id + returning vault_secret_id into v_secret; + if not found then raise exception 'credential not found'; end if; + if p_password is not null and p_password <> '' then + perform vault.update_secret(v_secret, p_password); + end if; +end $$; + +-- Klartext nur über diese Funktion, jeder Abruf wird protokolliert. +-- Erlaubt: Admins + der zugehörige Client. (Client-Zugriff entfernen: "or v_client = ..." löschen) +create or replace function public.reveal_credential(p_id uuid) +returns text language plpgsql security definer set search_path = '' as $$ +declare v_client uuid; v_secret uuid; v_pw text; +begin + select client_id, vault_secret_id into v_client, v_secret + from public.credentials where id = p_id; + if not found then raise exception 'credential not found'; end if; + if not (public.is_admin() or v_client = public.current_client_id()) then + raise exception 'forbidden' using errcode = '42501'; + end if; + select decrypted_secret into v_pw from vault.decrypted_secrets where id = v_secret; + insert into public.credential_access_log (credential_id, user_id) values (p_id, auth.uid()); + return v_pw; +end $$; + +-- Vault-Eintrag aufräumen, wenn Credential (oder ganzer Client) gelöscht wird +create or replace function public.delete_vault_secret() +returns trigger language plpgsql security definer set search_path = '' as $$ +begin + delete from vault.secrets where id = old.vault_secret_id; + return old; +end $$; + +create trigger credentials_delete_vault_secret + after delete on public.credentials + for each row execute function public.delete_vault_secret(); + +revoke execute on function public.create_credential(uuid, text, text, text) from public, anon; +revoke execute on function public.update_credential(uuid, text, text, text) from public, anon; +revoke execute on function public.reveal_credential(uuid) from public, anon; +revoke execute on function public.next_invoice_number() from public, anon; +grant execute on function public.create_credential(uuid, text, text, text) to authenticated; +grant execute on function public.update_credential(uuid, text, text, text) to authenticated; +grant execute on function public.reveal_credential(uuid) to authenticated; +grant execute on function public.next_invoice_number() to authenticated; + +-- --------------------------------------------------------------------- +-- 6. Dokumente (Metadaten in DB, Dateien in Storage) +-- --------------------------------------------------------------------- +create table public.folders ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + parent_id uuid, -- NULL = oberste Ebene + name text not null, + created_by uuid references public.profiles(id) on delete set null default auth.uid(), + created_at timestamptz not null default now(), + unique (id, client_id), + -- Ordner nur löschbar, wenn leer (kein ON DELETE-Verhalten = NO ACTION) + foreign key (parent_id, client_id) references public.folders (id, client_id), + unique nulls not distinct (client_id, parent_id, name) +); + +create table public.documents ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + folder_id uuid, -- NULL = außerhalb von Ordnern + name text not null, -- Originaldateiname + storage_path text not null unique, -- {client_id}/{document_id}/{dateiname} + mime_type text, + size_bytes bigint check (size_bytes >= 0), + uploaded_by uuid references public.profiles(id) on delete set null default auth.uid(), + created_at timestamptz not null default now(), + foreign key (folder_id, client_id) references public.folders (id, client_id) +); + +insert into storage.buckets (id, name, public) +values ('client-documents', 'client-documents', false) +on conflict (id) do nothing; + +create policy "client_documents_select" on storage.objects for select to authenticated + using (bucket_id = 'client-documents' + and ((select public.is_admin()) + or (storage.foldername(name))[1] = (select public.current_client_id())::text)); + +create policy "client_documents_insert" on storage.objects for insert to authenticated + with check (bucket_id = 'client-documents' + and ((select public.is_admin()) + or (storage.foldername(name))[1] = (select public.current_client_id())::text)); + +create policy "client_documents_delete" on storage.objects for delete to authenticated + using (bucket_id = 'client-documents' and (select public.is_admin())); + +-- --------------------------------------------------------------------- +-- 7. Info: Produkte, Vorgänge, interne Notizen +-- --------------------------------------------------------------------- +create table public.client_products ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + name text not null, + description text, + category public.product_category not null default 'website', + url text, + status public.product_status not null default 'online', + uptime_30d numeric(5,2) check (uptime_30d between 0 and 100), -- z. B. 99.98 (aus Monitoring-Tool) + uptime_updated_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (id, client_id) +); + +create table public.product_tasks ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + product_id uuid not null, + name text not null, + description text, + status public.task_status not null default 'planned', + since date not null default current_date, -- "seit 3 Tagen" (in_progress) bzw. "ab 6. Okt." (planned) + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (id, client_id), + foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade +); + +-- Statuswechsel setzt "seit" auf heute, außer der Admin setzt das Datum im selben Update explizit +create or replace function public.reset_task_since() +returns trigger language plpgsql set search_path = '' as $$ +begin + if new.status is distinct from old.status and new.since is not distinct from old.since then + new.since := current_date; + end if; + return new; +end $$; + +create trigger reset_task_since + before update on public.product_tasks + for each row execute function public.reset_task_since(); + +-- Eigene Tabelle, damit Clients Notizen nie sehen (RLS arbeitet zeilen-, nicht spaltenweise) +create table public.internal_notes ( + id uuid primary key default gen_random_uuid(), + client_id uuid not null references public.clients(id) on delete cascade, + product_id uuid, + task_id uuid, + body text not null, + created_by uuid references public.profiles(id) on delete set null default auth.uid(), + created_at timestamptz not null default now(), + foreign key (product_id, client_id) references public.client_products (id, client_id) on delete cascade, + foreign key (task_id, client_id) references public.product_tasks (id, client_id) on delete cascade +); + +-- --------------------------------------------------------------------- +-- 8. Indizes & updated_at-Trigger +-- --------------------------------------------------------------------- +create index on public.profiles (client_id); +create index on public.invoices (client_id, invoice_date desc); +create index on public.invoice_items (invoice_id); +create index on public.credentials (client_id); +create index on public.folders (client_id, parent_id); +create index on public.documents (client_id, folder_id); +create index on public.client_products (client_id); +create index on public.product_tasks (product_id); +create index on public.product_tasks (client_id); +create index on public.internal_notes (client_id); + +do $$ +declare t text; +begin + foreach t in array array['profiles','clients','client_payment_details','product_templates', + 'invoices','credentials','client_products','product_tasks'] loop + execute format('create trigger set_updated_at before update on public.%I + for each row execute function public.set_updated_at()', t); + end loop; +end $$; + +-- --------------------------------------------------------------------- +-- 9. Row Level Security +-- --------------------------------------------------------------------- +-- Ohne Policy = kein Zugriff. Alles nur für eingeloggte User (nie anon). + +-- 9a) Admin darf alles auf diesen Tabellen +do $$ +declare t text; +begin + foreach t in array array['clients','client_payment_details','product_templates','invoice_items', + 'folders','documents','client_products','product_tasks','internal_notes'] loop + execute format('alter table public.%I enable row level security', t); + execute format('create policy admin_all on public.%I for all to authenticated + using ((select public.is_admin())) with check ((select public.is_admin()))', t); + end loop; +end $$; + +-- 9b) profiles: lesen = eigenes Profil oder Admin; ändern = nur Admin (Rolle nicht selbst änderbar) +alter table public.profiles enable row level security; +create policy profiles_select on public.profiles for select to authenticated + using (id = (select auth.uid()) or (select public.is_admin())); +create policy profiles_admin_update on public.profiles for update to authenticated + using ((select public.is_admin())) with check ((select public.is_admin())); + +-- 9c) Rechnungen: Admin darf lesen/anlegen/ändern, aber NICHT hart löschen (-> Status 'cancelled' = Storno) +alter table public.invoices enable row level security; +create policy admin_select on public.invoices for select to authenticated using ((select public.is_admin())); +create policy admin_insert on public.invoices for insert to authenticated with check ((select public.is_admin())); +create policy admin_update on public.invoices for update to authenticated + using ((select public.is_admin())) with check ((select public.is_admin())); + +-- 9d) Passwörter: Schreiben nur über die Funktionen (Abschnitt 5), Löschen nur Admin +alter table public.credentials enable row level security; +create policy credentials_select on public.credentials for select to authenticated + using ((select public.is_admin()) or client_id = (select public.current_client_id())); +create policy credentials_admin_delete on public.credentials for delete to authenticated + using ((select public.is_admin())); + +alter table public.credential_access_log enable row level security; +create policy access_log_admin_select on public.credential_access_log for select to authenticated + using ((select public.is_admin())); + +-- 9e) Clients sehen nur ihre eigenen Daten +create policy client_select on public.clients for select to authenticated + using (id = (select public.current_client_id())); + +create policy client_select on public.client_payment_details for select to authenticated + using (client_id = (select public.current_client_id())); + +create policy client_select on public.invoices for select to authenticated + using (client_id = (select public.current_client_id()) and status <> 'cancelled'); + +create policy client_select on public.invoice_items for select to authenticated + using (exists (select 1 from public.invoices i + where i.id = invoice_id + and i.client_id = (select public.current_client_id()) + and i.status <> 'cancelled')); + +create policy client_select on public.folders for select to authenticated + using (client_id = (select public.current_client_id())); + +create policy client_select on public.documents for select to authenticated + using (client_id = (select public.current_client_id())); + +create policy client_insert on public.documents for insert to authenticated + with check (client_id = (select public.current_client_id()) and uploaded_by = (select auth.uid())); + +create policy client_select on public.client_products for select to authenticated + using (client_id = (select public.current_client_id())); + +create policy client_select on public.product_tasks for select to authenticated + using (client_id = (select public.current_client_id())); + +-- product_templates, internal_notes: für Clients bewusst keine Policy = unsichtbar \ No newline at end of file diff --git a/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql b/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql new file mode 100644 index 0000000..0e71033 --- /dev/null +++ b/lib/supabase/migrations/20260928151300_add_terms_acceptance.sql @@ -0,0 +1,2 @@ +alter table public.profiles + add column if not exists terms_accepted_at timestamptz; diff --git a/modules/admin/ui/components/clients-table.tsx b/modules/admin/ui/components/clients-table.tsx index 42f56d4..27c57b5 100644 --- a/modules/admin/ui/components/clients-table.tsx +++ b/modules/admin/ui/components/clients-table.tsx @@ -52,7 +52,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) { return (
- + { + const stammdaten = await getStammdaten(clientProfileId) + + return ( + + ) +} + +export default StammdatenAdminViewPage diff --git a/modules/client/ui/views/stammdaten-client-view.tsx b/modules/client/ui/views/stammdaten-client-view.tsx new file mode 100644 index 0000000..35a2918 --- /dev/null +++ b/modules/client/ui/views/stammdaten-client-view.tsx @@ -0,0 +1,18 @@ +import { verifySession } from "@/lib/supabase/dal" +import { getStammdaten } from "@/modules/shared/queries/stammdaten" +import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel" + +const StammdatenClientViewPage = async () => { + const session = await verifySession() + const stammdaten = await getStammdaten(session.userId) + + return ( + + ) +} + +export default StammdatenClientViewPage diff --git a/modules/loginPage/ui/components/stamdaten-form.tsx b/modules/loginPage/ui/components/stamdaten-form.tsx new file mode 100644 index 0000000..f17aecf --- /dev/null +++ b/modules/loginPage/ui/components/stamdaten-form.tsx @@ -0,0 +1,310 @@ +"use client" + +import { useActionState, useEffect, useState } from "react"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { saveStammdaten, StammdatenState } from "@/modules/shared/actions/stammdaten"; +import { + Building2, + CreditCard, + FileText, + MapPin, + User, + type LucideIcon, +} from "lucide-react"; +import { LEGAL_FORMS } from "@/modules/shared/data/stammdaten-data"; + +type Client = { + company_name: string; + legal_form: string; + industry: string | null; + vat_id: string | null; + tax_number: string | null; + commercial_register_number: string | null; + street: string; + postal_code: string; + city: string; + country: string; + billing_street: string | null; + billing_postal_code: string | null; + billing_city: string | null; + billing_country: string | null; + contact_name: string; + contact_position: string | null; + contact_email: string; + contact_phone: string | null; +} | null; + +type PaymentDetails = { + payment_method: "invoice" | "sepa_debit" | "card"; + account_holder: string | null; + iban: string | null; + bic: string | null; +} | null; + +const selectClassName = + "flex h-10 w-full rounded-md border border-input bg-background px-3 text-body outline-none transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"; + +function FormSection({ + icon: Icon, + title, + children, +}: { + icon: LucideIcon; + title: string; + children: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ + {children} + +
+ ); +} + +function TextField({ + name, + label, + defaultValue, + type = "text", + required, + disabled, +}: { + name: string; + label: string; + defaultValue?: string | null; + type?: string; + required?: boolean; + disabled?: boolean; +}) { + return ( + + + {label} {required && *} + + + + ); +} + +function SelectField({ + name, + label, + value, + defaultValue, + onChange, + options, + required, + disabled, +}: { + name: string; + label: string; + value?: string; + defaultValue?: string; + onChange?: (value: string) => void; + options: { value: string; label: string }[]; + required?: boolean; + disabled?: boolean; +}) { + return ( + + + {label} {required && *} + + + + ); +} + +const StammdatenForm = ({ + profileId, + client, + paymentDetails, + isEditing, + onDone, +}: { + profileId: string; + client: Client; + paymentDetails: PaymentDetails; + isEditing: boolean; + onDone: () => void; +}) => { + const [state, formAction, pending] = useActionState( + saveStammdaten, + undefined + ); + const [paymentMethod, setPaymentMethod] = useState(paymentDetails?.payment_method ?? "invoice"); + const [hasDifferentBillingAddress, setHasDifferentBillingAddress] = useState( + Boolean(client?.billing_street) + ); + + useEffect(() => { + if (state && "success" in state && state.success) { + onDone(); + } + }, [state, onDone]); + + const disabled = !isEditing || pending; + + return ( +
+ + +
+ + + ({ value: form, label: form }))} + /> + + + + + + + + + + + + + +
+ + {hasDifferentBillingAddress && ( + + + + + + + )} +
+ + + + + + + + + + setPaymentMethod(value as typeof paymentMethod)} + disabled={disabled} + options={[ + { value: "invoice", label: "Rechnung" }, + { value: "sepa_debit", label: "SEPA-Lastschrift" }, + { value: "card", label: "Kreditkarte" }, + ]} + /> + {paymentMethod === "sepa_debit" && ( + <> + + + + + )} + +
+ + {state && "error" in state && ( +

{state.error}

+ )} + + {isEditing && ( +
+ + +
+ )} +
+ ); +}; + +export default StammdatenForm; diff --git a/modules/loginPage/ui/views/login-view.tsx b/modules/loginPage/ui/views/login-view.tsx index 09d77af..ea90c69 100644 --- a/modules/loginPage/ui/views/login-view.tsx +++ b/modules/loginPage/ui/views/login-view.tsx @@ -19,7 +19,13 @@ const LoginPageView = () => { width={1617} height={332} loading="eager" - className="h-5 w-auto md:h-6"/> + className="h-5 w-auto md:h-6 dark:hidden"/> +

Login

@@ -30,7 +36,7 @@ const LoginPageView = () => { id="form-email" name="email" type="text" - className="bg-white focus-visible:ring-0" + className="focus-visible:ring-0" required /> @@ -41,7 +47,7 @@ const LoginPageView = () => { id="form-password" name="password" type={showPassword ? "text" : "password"} - className="bg-white focus-visible:ring-0 pr-10" + className="focus-visible:ring-0 pr-10" required />
-

Bei Fragen: info@since3.de

+

Bei Fragen: info@since3.de

) diff --git a/modules/shared/actions/acceptTerms.ts b/modules/shared/actions/acceptTerms.ts new file mode 100644 index 0000000..913b52c --- /dev/null +++ b/modules/shared/actions/acceptTerms.ts @@ -0,0 +1,22 @@ +"use server"; + +import { createAdminClient } from "@/lib/supabase/admin"; +import { verifySession } from "@/lib/supabase/dal"; + +export async function acceptTerms() { + const session = await verifySession(); + const supabase = createAdminClient(); + + const { error } = await supabase + .from("profiles") + .update({ terms_accepted_at: new Date().toISOString() }) + .eq("id", session.userId) + + if (error) return { + error: "Zustimmung konnte nicht gespeichert werden." + }; + + return { + success: true + } +} \ No newline at end of file diff --git a/modules/shared/actions/stammdaten.ts b/modules/shared/actions/stammdaten.ts new file mode 100644 index 0000000..35b4eba --- /dev/null +++ b/modules/shared/actions/stammdaten.ts @@ -0,0 +1,178 @@ +"use server"; + +import * as z from "zod"; +import { revalidatePath } from "next/cache"; +import { createAdminClient } from "@/lib/supabase/admin"; +import { verifySession } from "@/lib/supabase/dal"; + +const StammdatenSchema = z.object({ + profileId: z.string().uuid(), + + companyName: z.string().min(1, { error: "Firmenname ist erforderlich." }), + legalForm: z.string().min(1, { error: "Rechtsform ist erforderlich." }), + industry: z.string().optional(), + vatId: z.string().optional(), + taxNumber: z.string().optional(), + commercialRegisterNumber: z.string().optional(), + + street: z.string().min(1, { error: "Straße ist erforderlich." }), + postalCode: z.string().min(1, { error: "PLZ ist erforderlich." }), + city: z.string().min(1, { error: "Stadt ist erforderlich." }), + country: z.string().length(2).default("DE"), + + billingStreet: z.string().optional(), + billingPostalCode: z.string().optional(), + billingCity: z.string().optional(), + billingCountry: z.string().optional(), + + contactName: z.string().min(1, { error: "Ansprechpartner ist erforderlich." }), + contactPosition: z.string().optional(), + contactEmail: z.email({ error: "Bitte eine gültige Email eingeben." }), + contactPhone: z.string().optional(), + + paymentMethod: z.enum(["invoice", "sepa_debit", "card"]).default("invoice"), + accountHolder: z.string().optional(), + iban: z.string().optional(), + bic: z.string().optional(), +}); + +export type StammdatenState = { error: string } | { success: true } | undefined; + +function emptyToUndefined(value: FormDataEntryValue | null) { + const stringValue = String(value ?? "").trim(); + return stringValue === "" ? undefined : stringValue; +} + +export async function saveStammdaten( + _prevState: StammdatenState, + formData: FormData +): Promise { + const session = await verifySession(); + const profileId = String(formData.get("profileId") ?? ""); + + if (!session.isAdmin && session.userId !== profileId) { + return { error: "Keine Berechtigung." }; + } + + const validatedFields = StammdatenSchema.safeParse({ + profileId, + companyName: emptyToUndefined(formData.get("companyName")), + legalForm: emptyToUndefined(formData.get("legalForm")), + industry: emptyToUndefined(formData.get("industry")), + vatId: emptyToUndefined(formData.get("vatId")), + taxNumber: emptyToUndefined(formData.get("taxNumber")), + commercialRegisterNumber: emptyToUndefined(formData.get("commercialRegisterNumber")), + + street: emptyToUndefined(formData.get("street")), + postalCode: emptyToUndefined(formData.get("postalCode")), + city: emptyToUndefined(formData.get("city")), + country: emptyToUndefined(formData.get("country")) ?? "DE", + + billingStreet: emptyToUndefined(formData.get("billingStreet")), + billingPostalCode: emptyToUndefined(formData.get("billingPostalCode")), + billingCity: emptyToUndefined(formData.get("billingCity")), + billingCountry: emptyToUndefined(formData.get("billingCountry")), + + contactName: emptyToUndefined(formData.get("contactName")), + contactPosition: emptyToUndefined(formData.get("contactPosition")), + contactEmail: emptyToUndefined(formData.get("contactEmail")), + contactPhone: emptyToUndefined(formData.get("contactPhone")), + + paymentMethod: emptyToUndefined(formData.get("paymentMethod")) ?? "invoice", + accountHolder: emptyToUndefined(formData.get("accountHolder")), + iban: emptyToUndefined(formData.get("iban")), + bic: emptyToUndefined(formData.get("bic")), + }); + + if (!validatedFields.success) { + const { fieldErrors } = z.flattenError(validatedFields.error); + const firstError = Object.values(fieldErrors).flat()[0]; + return { error: firstError ?? "Bitte alle Pflichtfelder ausfüllen." }; + } + + const { + billingStreet, + billingPostalCode, + billingCity, + billingCountry, + paymentMethod, + accountHolder, + iban, + bic, + ...data + } = validatedFields.data; + + const supabase = createAdminClient(); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("client_id") + .eq("id", data.profileId) + .single(); + + if (profileError || !profile) { + return { error: "Profil konnte nicht geladen werden." }; + } + + const clientPayload = { + company_name: data.companyName, + legal_form: data.legalForm, + industry: data.industry ?? null, + vat_id: data.vatId ?? null, + tax_number: data.taxNumber ?? null, + commercial_register_number: data.commercialRegisterNumber ?? null, + street: data.street, + postal_code: data.postalCode, + city: data.city, + country: data.country, + billing_street: billingStreet ?? null, + billing_postal_code: billingPostalCode ?? null, + billing_city: billingCity ?? null, + billing_country: billingCountry ?? null, + contact_name: data.contactName, + contact_position: data.contactPosition ?? null, + contact_email: data.contactEmail, + contact_phone: data.contactPhone ?? null, + }; + + let clientId = profile.client_id; + + if (clientId) { + const { error } = await supabase.from("clients").update(clientPayload).eq("id", clientId); + if (error) return { error: "Stammdaten konnten nicht gespeichert werden." }; + } else { + const { data: newClient, error } = await supabase + .from("clients") + .insert(clientPayload) + .select("id") + .single(); + + if (error || !newClient) return { error: "Stammdaten konnten nicht gespeichert werden." }; + + clientId = newClient.id; + + const { error: linkError } = await supabase + .from("profiles") + .update({ client_id: clientId }) + .eq("id", data.profileId); + + if (linkError) return { error: "Verknüpfung mit Profil fehlgeschlagen." }; + } + + const { error: paymentError } = await supabase.from("client_payment_details").upsert({ + client_id: clientId, + payment_method: paymentMethod, + account_holder: accountHolder ?? null, + iban: iban ?? null, + bic: bic ?? null, + }); + + if (paymentError) { + return { error: "Zahlungsdaten konnten nicht gespeichert werden." }; + } + + revalidatePath("/client/data"); + revalidatePath(`/admin/clients/${data.profileId}/data`); + + return { success: true }; +} diff --git a/modules/shared/data/stammdaten-data.ts b/modules/shared/data/stammdaten-data.ts new file mode 100644 index 0000000..6a8b2c0 --- /dev/null +++ b/modules/shared/data/stammdaten-data.ts @@ -0,0 +1,14 @@ + + +export const LEGAL_FORMS = [ + "Einzelunternehmen", + "GbR", + "GmbH", + "UG (haftungsbeschränkt)", + "AG", + "OHG", + "KG", + "GmbH & Co. KG", + "e.K.", + "e.V.", +]; diff --git a/modules/shared/queries/stammdaten.ts b/modules/shared/queries/stammdaten.ts new file mode 100644 index 0000000..59dde78 --- /dev/null +++ b/modules/shared/queries/stammdaten.ts @@ -0,0 +1,25 @@ +import "server-only" +import { createAdminClient } from "@/lib/supabase/admin" + +export async function getStammdaten(profileId: string) { + const supabase = createAdminClient() + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("client_id") + .eq("id", profileId) + .single() + + if (profileError || !profile) return null + + if (!profile.client_id) { + return { client: null, paymentDetails: null } + } + + const [{ data: client }, { data: paymentDetails }] = await Promise.all([ + supabase.from("clients").select("*").eq("id", profile.client_id).single(), + supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).single(), + ]) + + return { client: client ?? null, paymentDetails: paymentDetails ?? null } +} diff --git a/modules/shared/ui/components/stammdaten-panel.tsx b/modules/shared/ui/components/stammdaten-panel.tsx new file mode 100644 index 0000000..f7771ca --- /dev/null +++ b/modules/shared/ui/components/stammdaten-panel.tsx @@ -0,0 +1,42 @@ +"use client" + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Pen } from "lucide-react"; +import StammdatenForm from "@/modules/loginPage/ui/components/stamdaten-form"; + +type Client = React.ComponentProps["client"]; +type PaymentDetails = React.ComponentProps["paymentDetails"]; + +export function StammdatenPanel({ + profileId, + client, + paymentDetails, +}: { + profileId: string; + client: Client; + paymentDetails: PaymentDetails; +}) { + const [isEditing, setIsEditing] = useState(false); + + return ( +
+
+

Stammdaten

+ {!isEditing && ( + + )} +
+ setIsEditing(false)} + /> +
+ ); +} diff --git a/modules/shared/ui/components/terms-dialog.tsx b/modules/shared/ui/components/terms-dialog.tsx new file mode 100644 index 0000000..cd07041 --- /dev/null +++ b/modules/shared/ui/components/terms-dialog.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { acceptTerms } from "../../actions/acceptTerms"; +import { useRouter } from "next/navigation"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + + +export function TermsDialog() { + const [open, setOpen] = useState(true); + const [pending, setPending] = useState(false); + const router = useRouter(); + + async function handleAccept() { + setPending(true); + const result = await acceptTerms(); + + if (result && "success" in result) { + setOpen(false); + router.refresh(); + } + setPending(false); + } + + return ( + {}}> + + + Nutzungsbedingungen + + Bitte akzeptiere unsere Nutzungsbedingungen, um fortzufahren. + + + + + + + + ) +} \ No newline at end of file