Merge pull request #4 from Since-3/feature/added-stammdaten

added stammdaten form
This commit is contained in:
Rajpreet0 2026-09-28 16:23:47 +02:00 • committed by GitHub
commit f218d472fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2734 additions and 7 deletions

37
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: CI Pipeline for S3-Portal
on:
push:
branches: [main, dev]
pull_request:
branches: [dev]
env:
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY }}
SUPABASE_SECRET_KEY: ${{ secrets.SUPABASE_SECRET_KEY }}
jobs:
build-and-test:
name: Build, Lint
runs-on: ubuntu-latest
steps:
- name: 🧩 Checkout repository
uses: actions/checkout@v4
- name: 🛠 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: 📦 Install dependencies
run: npm install
- name: 🔍 Lint code
run: npm run lint --workspaces=false
- name: 🏗 Build Next.js app
run: npm run build

View file

@ -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 <StammdatenAdminViewPage clientProfileId={id} />
}
export default DataAdminPage

View file

@ -2,15 +2,17 @@ import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal"; import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; 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 }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await verifySession() const session = await verifySession()
const cookieStore = await cookies() const cookieStore = await cookies()
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false" const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
return ( return (
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen}>
{!session.isAdmin && !session.hasAcceptedTerms && <TermsDialog/>}
<AppSidebar session={session}/> <AppSidebar session={session}/>
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<div className="flex items-center border-b border-border p-3 md:hidden"> <div className="flex items-center border-b border-border p-3 md:hidden">

7
app/client/data/page.tsx Normal file
View file

@ -0,0 +1,7 @@
import StammdatenClientViewPage from "@/modules/client/ui/views/stammdaten-client-view"
const DataPage = () => {
return <StammdatenClientViewPage/>
}
export default DataPage

View file

@ -2,6 +2,7 @@ import { cookies } from "next/headers";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { verifySession } from "@/lib/supabase/dal"; import { verifySession } from "@/lib/supabase/dal";
import { AppSidebar } from "@/modules/shared/ui/components/app-sidebar"; 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 }) { export default async function ClientLayout({ children }: { children: React.ReactNode }) {
@ -9,6 +10,10 @@ export default async function ClientLayout({ children }: { children: React.React
const cookieStore = await cookies() const cookieStore = await cookies()
const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false" const defaultOpen = cookieStore.get("sidebar_state")?.value !== "false"
if (!session.isAdmin && !session.hasAcceptedTerms) {
return <TermsDialog />
}
return ( return (
<SidebarProvider defaultOpen={defaultOpen}> <SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar session={session}/> <AppSidebar session={session}/>

12
app/terms/page.tsx Normal file
View file

@ -0,0 +1,12 @@
const TermsPage = () => {
return (
<div className="mx-auto max-w-2xl p-8">
<h1 className="mb-4 text-2xl font-semibold">Nutzungsbedingungen</h1>
<p className="text-muted-foreground">
TODO: Nutzungsbedingungen einfügen.
</p>
</div>
)
}
export default TermsPage

View file

@ -18,7 +18,7 @@ export const getProfile = cache(async () => {
const supabase = await createClient() const supabase = await createClient()
const { data } = await supabase const { data } = await supabase
.from("profiles") .from("profiles")
.select("role, name, email") .select("role, name, email, terms_accepted_at")
.eq("id", user.id) .eq("id", user.id)
.single() .single()
@ -41,5 +41,6 @@ export const verifySession = cache(async () => {
isLoggedIn: true, isLoggedIn: true,
isAdmin: profile.role === "admin", isAdmin: profile.role === "admin",
isClient: profile.role === "client", isClient: profile.role === "client",
hasAcceptedTerms: profile.terms_accepted_at != null,
} }
}) })

View file

@ -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 <dein_alter_trigger> 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

View file

@ -0,0 +1,2 @@
alter table public.profiles
add column if not exists terms_accepted_at timestamptz;

View file

@ -0,0 +1,69 @@
-- Legt einen neuen clients-Datensatz an und verknüpft ihn atomar mit profiles.client_id.
-- Der "where client_id is null"-Guard verhindert, dass zwei parallele Submits
-- jeweils einen eigenen clients-Datensatz erzeugen und verwaisen lassen.
create or replace function public.create_client_and_link_profile(
p_profile_id uuid,
p_company_name text,
p_legal_form text,
p_industry text,
p_vat_id text,
p_tax_number text,
p_commercial_register_number text,
p_street text,
p_postal_code text,
p_city text,
p_country char(2),
p_billing_street text,
p_billing_postal_code text,
p_billing_city text,
p_billing_country char(2),
p_contact_name text,
p_contact_position text,
p_contact_email text,
p_contact_phone text
) returns uuid
language plpgsql
security definer
set search_path = ''
as $$
declare
v_client_id uuid;
v_updated_rows int;
begin
insert into public.clients (
company_name, legal_form, industry, vat_id, tax_number, commercial_register_number,
street, postal_code, city, country,
billing_street, billing_postal_code, billing_city, billing_country,
contact_name, contact_position, contact_email, contact_phone
) values (
p_company_name, p_legal_form, p_industry, p_vat_id, p_tax_number, p_commercial_register_number,
p_street, p_postal_code, p_city, p_country,
p_billing_street, p_billing_postal_code, p_billing_city, p_billing_country,
p_contact_name, p_contact_position, p_contact_email, p_contact_phone
)
returning id into v_client_id;
update public.profiles
set client_id = v_client_id
where id = p_profile_id
and client_id is null;
get diagnostics v_updated_rows = row_count;
if v_updated_rows = 0 then
raise exception 'profile already linked to a client' using errcode = '23505';
end if;
return v_client_id;
end;
$$;
revoke execute on function public.create_client_and_link_profile(
uuid, text, text, text, text, text, text, text, text, text, char(2),
text, text, text, char(2), text, text, text, text
) from public, anon;
grant execute on function public.create_client_and_link_profile(
uuid, text, text, text, text, text, text, text, text, text, char(2),
text, text, text, char(2), text, text, text, text
) to authenticated;

View file

@ -52,7 +52,7 @@ export function ClientsTable({ clients }: { clients: Client[] }) {
return ( return (
<div className="flex flex-col gap-8 w-full"> <div className="flex flex-col gap-8 w-full">
<div className="flex flex-row items-center justify-between gap-2 w-full p-2"> <div className="flex flex-row items-center justify-between gap-2 w-full p-2">
<InputGroup className="max-w-lg bg-white rounded-md focus-visible:ring-0"> <InputGroup className="max-w-lg rounded-md focus-visible:ring-0">
<InputGroupInput <InputGroupInput
placeholder="Search..." placeholder="Search..."
value={query} value={query}

View file

@ -0,0 +1,16 @@
import { getStammdaten } from "@/modules/shared/queries/stammdaten"
import { StammdatenPanel } from "@/modules/shared/ui/components/stammdaten-panel"
const StammdatenAdminViewPage = async ({ clientProfileId }: { clientProfileId: string }) => {
const stammdaten = await getStammdaten(clientProfileId)
return (
<StammdatenPanel
profileId={clientProfileId}
client={stammdaten?.client ?? null}
paymentDetails={stammdaten?.paymentDetails ?? null}
/>
)
}
export default StammdatenAdminViewPage

View file

@ -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 (
<StammdatenPanel
profileId={session.userId}
client={stammdaten?.client ?? null}
paymentDetails={stammdaten?.paymentDetails ?? null}
/>
)
}
export default StammdatenClientViewPage

View file

@ -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 (
<div className="rounded-lg border border-border bg-surface/50 p-5">
<div className="mb-5 flex items-center gap-2">
<Icon className="size-4.5 text-muted-foreground" />
<h2 className="text-base font-semibold">{title}</h2>
</div>
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
{children}
</FieldGroup>
</div>
);
}
function TextField({
name,
label,
defaultValue,
type = "text",
required,
disabled,
}: {
name: string;
label: string;
defaultValue?: string | null;
type?: string;
required?: boolean;
disabled?: boolean;
}) {
return (
<Field>
<FieldLabel htmlFor={name}>
{label} {required && <span className="text-destructive">*</span>}
</FieldLabel>
<Input
id={name}
name={name}
type={type}
defaultValue={defaultValue ?? ""}
required={required}
disabled={disabled}
className="focus-visible:ring-0"
/>
</Field>
);
}
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 (
<Field>
<FieldLabel htmlFor={name}>
{label} {required && <span className="text-destructive">*</span>}
</FieldLabel>
<select
id={name}
name={name}
value={value}
defaultValue={defaultValue}
onChange={onChange ? (event) => onChange(event.target.value) : undefined}
disabled={disabled}
required={required}
className={selectClassName}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Field>
);
}
const StammdatenForm = ({
profileId,
client,
paymentDetails,
isEditing,
onDone,
}: {
profileId: string;
client: Client;
paymentDetails: PaymentDetails;
isEditing: boolean;
onDone: () => void;
}) => {
const [state, formAction, pending] = useActionState<StammdatenState, FormData>(
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 (
<form action={formAction} className="flex flex-col gap-6">
<input type="hidden" name="profileId" value={profileId} />
<fieldset disabled={disabled} className="flex flex-col gap-6">
<FormSection icon={Building2} title="Firma">
<TextField name="companyName" label="Firmenname" defaultValue={client?.company_name} required />
<SelectField
name="legalForm"
label="Rechtsform"
defaultValue={client?.legal_form ?? LEGAL_FORMS[0]}
disabled={disabled}
required
options={LEGAL_FORMS.map((form) => ({ value: form, label: form }))}
/>
<TextField name="industry" label="Branche" defaultValue={client?.industry} />
<TextField name="vatId" label="USt-IdNr" defaultValue={client?.vat_id} />
<TextField name="taxNumber" label="Steuernummer" defaultValue={client?.tax_number} />
<TextField
name="commercialRegisterNumber"
label="Handelsregisternummer"
defaultValue={client?.commercial_register_number}
/>
</FormSection>
<FormSection icon={MapPin} title="Firmenadresse">
<TextField name="street" label="Straße & Hausnummer" defaultValue={client?.street} required />
<TextField name="postalCode" label="PLZ" defaultValue={client?.postal_code} required />
<TextField name="city" label="Stadt" defaultValue={client?.city} required />
<SelectField
name="country"
label="Land"
defaultValue={client?.country ?? "DE"}
disabled={disabled}
options={[
{ value: "DE", label: "Deutschland" },
{ value: "AT", label: "Österreich" },
{ value: "CH", label: "Schweiz" },
]}
/>
</FormSection>
<div className="rounded-lg border border-border bg-surface/50 p-5">
<label className="mb-5 flex items-center gap-2 has-disabled:opacity-50">
<input
type="checkbox"
checked={hasDifferentBillingAddress}
onChange={(event) => setHasDifferentBillingAddress(event.target.checked)}
disabled={disabled}
className="size-4 rounded border-input accent-primary"
/>
<FileText className="size-4.5 text-muted-foreground" />
<span className="text-base font-semibold">Abweichende Rechnungsadresse</span>
</label>
{hasDifferentBillingAddress && (
<FieldGroup className="@container grid grid-cols-1 gap-4 @md:grid-cols-2">
<TextField
name="billingStreet"
label="Straße & Hausnummer"
defaultValue={client?.billing_street}
required
/>
<TextField
name="billingPostalCode"
label="PLZ"
defaultValue={client?.billing_postal_code}
required
/>
<TextField name="billingCity" label="Stadt" defaultValue={client?.billing_city} required />
<TextField
name="billingCountry"
label="Land"
defaultValue={client?.billing_country}
required
/>
</FieldGroup>
)}
</div>
<FormSection icon={User} title="Ansprechpartner">
<TextField name="contactName" label="Name" defaultValue={client?.contact_name} required />
<TextField name="contactPosition" label="Position" defaultValue={client?.contact_position} />
<TextField name="contactEmail" label="Email" type="email" defaultValue={client?.contact_email} required />
<TextField name="contactPhone" label="Telefon" defaultValue={client?.contact_phone} />
</FormSection>
<FormSection icon={CreditCard} title="Zahlungsdaten">
<SelectField
name="paymentMethod"
label="Zahlungsart"
value={paymentMethod}
onChange={(value) => 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" && (
<>
<TextField
name="accountHolder"
label="Kontoinhaber"
defaultValue={paymentDetails?.account_holder}
required
/>
<TextField name="iban" label="IBAN" defaultValue={paymentDetails?.iban} required />
<TextField name="bic" label="BIC" defaultValue={paymentDetails?.bic} />
</>
)}
</FormSection>
</fieldset>
{state && "error" in state && (
<p className="text-sm text-destructive">{state.error}</p>
)}
{isEditing && (
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onDone} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" disabled={pending}>
{pending ? "Wird gespeichert..." : "Speichern"}
</Button>
</div>
)}
</form>
);
};
export default StammdatenForm;

View file

@ -19,7 +19,13 @@ const LoginPageView = () => {
width={1617} width={1617}
height={332} height={332}
loading="eager" loading="eager"
className="h-5 w-auto md:h-6"/> className="h-5 w-auto md:h-6 dark:hidden"/>
<Image
src="/pictures/since3-logo-weiss.svg" alt=""
width={1617}
height={332}
loading="eager"
className="hidden h-5 w-auto md:h-6 dark:block"/>
<div className="flex flex-col h-full items-center justify-center"> <div className="flex flex-col h-full items-center justify-center">
<h1 className="mb-6 text-3xl font-semibold">Login</h1> <h1 className="mb-6 text-3xl font-semibold">Login</h1>
<form action={formAction} className="w-full max-w-sm "> <form action={formAction} className="w-full max-w-sm ">
@ -30,7 +36,7 @@ const LoginPageView = () => {
id="form-email" id="form-email"
name="email" name="email"
type="text" type="text"
className="bg-white focus-visible:ring-0" className="focus-visible:ring-0"
required required
/> />
</Field> </Field>
@ -41,7 +47,7 @@ const LoginPageView = () => {
id="form-password" id="form-password"
name="password" name="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
className="bg-white focus-visible:ring-0 pr-10" className="focus-visible:ring-0 pr-10"
required required
/> />
<button <button
@ -67,7 +73,7 @@ const LoginPageView = () => {
</form> </form>
</div> </div>
<div className="absolute bottom-2"> <div className="absolute bottom-2">
<p className="text-accent-400 text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p> <p className="text-muted-foreground text-sm">Bei Fragen: <a href="mailto:info@since3.de" className="underline">info@since3.de</a></p>
</div> </div>
</div> </div>
) )

View file

@ -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
}
}

View file

@ -0,0 +1,230 @@
"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 IBAN_REGEX = /^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$/;
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(),
})
.superRefine((data, ctx) => {
if (data.paymentMethod === "sepa_debit") {
if (!data.accountHolder) {
ctx.addIssue({
code: "custom",
path: ["accountHolder"],
message: "Kontoinhaber ist bei SEPA-Lastschrift erforderlich.",
});
}
if (!data.iban) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message: "IBAN ist bei SEPA-Lastschrift erforderlich.",
});
} else if (!IBAN_REGEX.test(data.iban)) {
ctx.addIssue({
code: "custom",
path: ["iban"],
message: "Bitte eine gültige IBAN eingeben.",
});
}
}
const billingFields = [
data.billingStreet,
data.billingPostalCode,
data.billingCity,
data.billingCountry,
];
const filledCount = billingFields.filter(Boolean).length;
if (filledCount > 0 && filledCount < billingFields.length) {
ctx.addIssue({
code: "custom",
path: ["billingStreet"],
message: "Bitte alle Felder der Rechnungsadresse ausfüllen oder alle leer lassen.",
});
}
});
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<StammdatenState> {
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: newClientId, error } = await supabase.rpc("create_client_and_link_profile", {
p_profile_id: data.profileId,
p_company_name: clientPayload.company_name,
p_legal_form: clientPayload.legal_form,
p_industry: clientPayload.industry,
p_vat_id: clientPayload.vat_id,
p_tax_number: clientPayload.tax_number,
p_commercial_register_number: clientPayload.commercial_register_number,
p_street: clientPayload.street,
p_postal_code: clientPayload.postal_code,
p_city: clientPayload.city,
p_country: clientPayload.country,
p_billing_street: clientPayload.billing_street,
p_billing_postal_code: clientPayload.billing_postal_code,
p_billing_city: clientPayload.billing_city,
p_billing_country: clientPayload.billing_country,
p_contact_name: clientPayload.contact_name,
p_contact_position: clientPayload.contact_position,
p_contact_email: clientPayload.contact_email,
p_contact_phone: clientPayload.contact_phone,
});
if (error || !newClientId) return { error: "Stammdaten konnten nicht gespeichert werden." };
clientId = newClientId;
}
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 };
}

View file

@ -0,0 +1,14 @@
export const LEGAL_FORMS = [
"Einzelunternehmen",
"GbR",
"GmbH",
"UG (haftungsbeschränkt)",
"AG",
"OHG",
"KG",
"GmbH & Co. KG",
"e.K.",
"e.V.",
];

View file

@ -0,0 +1,29 @@
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 [clientRes, paymentRes] = await Promise.all([
supabase.from("clients").select("*").eq("id", profile.client_id).maybeSingle(),
supabase.from("client_payment_details").select("*").eq("client_id", profile.client_id).maybeSingle(),
])
if (clientRes.error || paymentRes.error) {
throw new Error("Stammdaten konnten nicht geladen werden.")
}
return { client: clientRes.data, paymentDetails: paymentRes.data }
}

View file

@ -0,0 +1,48 @@
"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<typeof StammdatenForm>["client"];
type PaymentDetails = React.ComponentProps<typeof StammdatenForm>["paymentDetails"];
export function StammdatenPanel({
profileId,
client,
paymentDetails,
}: {
profileId: string;
client: Client;
paymentDetails: PaymentDetails;
}) {
const [isEditing, setIsEditing] = useState(false);
const [resetCount, setResetCount] = useState(0);
function handleDone() {
setIsEditing(false);
setResetCount((count) => count + 1);
}
return (
<div className="flex flex-col gap-8 w-full">
<div className="w-full flex flex-row justify-between items-center">
<h1 className="text-3xl font-semibold">Stammdaten</h1>
{!isEditing && (
<Button variant="outline" onClick={() => setIsEditing(true)}>
<Pen className="w-4 h-4 mr-2" /> Edit
</Button>
)}
</div>
<StammdatenForm
key={`${resetCount}-${JSON.stringify({ client, paymentDetails })}`}
profileId={profileId}
client={client}
paymentDetails={paymentDetails}
isEditing={isEditing}
onDone={handleDone}
/>
</div>
);
}

View file

@ -0,0 +1,58 @@
"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 [error, setError] = useState<string | null>(null);
const router = useRouter();
async function handleAccept() {
setPending(true);
setError(null);
try {
const result = await acceptTerms();
if (result && "success" in result) {
setOpen(false);
router.refresh();
} else {
setError(result?.error ?? "Zustimmung konnte nicht gespeichert werden.");
}
} catch {
setError("Zustimmung konnte nicht gespeichert werden. Bitte erneut versuchen.");
} finally {
setPending(false);
}
}
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Nutzungsbedingungen</DialogTitle>
<DialogDescription>
Bitte lies unsere{" "}
<a href="/terms" target="_blank" rel="noopener noreferrer">
Nutzungsbedingungen
</a>{" "}
und akzeptiere sie, um fortzufahren.
</DialogDescription>
</DialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button onClick={handleAccept} disabled={pending}>
{pending ? "..." : "Akzeptieren"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

1288
package-lock.json generated

File diff suppressed because it is too large Load diff