Compare commits

...

16 Commits

Author SHA1 Message Date
52c182cb5f Fixes
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 3m8s
Build and Push Docker Images / build-frontend (push) Successful in 1m15s
2026-03-04 20:44:19 +01:00
9cef3964e9 Serienrechnungen ausführung sowie Anwahl und liste 2026-03-04 19:54:12 +01:00
cf0fb724a2 Fix #126 2026-02-22 19:33:56 +01:00
bbb893dd6c Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-21 22:41:23 +01:00
724f152d70 Fix #116 2026-02-21 22:41:07 +01:00
27be8241bf Initial for #123 2026-02-21 22:23:32 +01:00
d27e437ba6 Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:23:32 +01:00
f5253b29f4 Fix #113 2026-02-21 22:23:31 +01:00
0141a243ce Initial for #123
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 31s
Build and Push Docker Images / build-frontend (push) Successful in 1m10s
2026-02-21 22:21:10 +01:00
a0e1b8c0eb Fix Error in IcomingInvoice Opening of Drafts 2026-02-21 22:19:45 +01:00
45fb45845a Fix #116 2026-02-21 22:17:58 +01:00
409db82368 Mobile Dev
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 2m50s
Build and Push Docker Images / build-frontend (push) Successful in 1m13s
2026-02-21 21:21:39 +01:00
30d761f899 fix memberrlation 2026-02-21 21:21:27 +01:00
70636f6ac5 Fixed FinalInvoice
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 32s
Build and Push Docker Images / build-frontend (push) Successful in 1m9s
2026-02-20 09:20:55 +01:00
59392a723c Time Page 2026-02-19 18:33:24 +01:00
c782492ab5 Initial Mobile 2026-02-19 18:29:06 +01:00
82 changed files with 18978 additions and 720 deletions

View File

@@ -0,0 +1,33 @@
ALTER TABLE "customers" ADD COLUMN IF NOT EXISTS "memberrelation" bigint;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'customers_memberrelation_memberrelations_id_fk'
) THEN
ALTER TABLE "customers"
ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk"
FOREIGN KEY ("memberrelation")
REFERENCES "public"."memberrelations"("id")
ON DELETE no action
ON UPDATE no action;
END IF;
END $$;
UPDATE "customers"
SET "memberrelation" = ("infoData"->>'memberrelation')::bigint
WHERE
"memberrelation" IS NULL
AND "type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation'
AND ("infoData"->>'memberrelation') ~ '^[0-9]+$';
UPDATE "customers"
SET "infoData" = COALESCE("infoData", '{}'::jsonb) - 'memberrelation'
WHERE
"type" = 'Mitglied'
AND jsonb_typeof(COALESCE("infoData", '{}'::jsonb)) = 'object'
AND COALESCE("infoData", '{}'::jsonb) ? 'memberrelation';

View File

@@ -0,0 +1,108 @@
CREATE TABLE "contracttypes" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "contracttypes_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"name" text NOT NULL,
"description" text,
"paymentType" text,
"recurring" boolean DEFAULT false NOT NULL,
"billingInterval" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerinventoryitems" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerinventoryitems_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"description" text,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"customerspace" bigint,
"customerInventoryId" text NOT NULL,
"serialNumber" text,
"quantity" bigint DEFAULT 0 NOT NULL,
"manufacturer" text,
"manufacturerNumber" text,
"purchaseDate" date,
"purchasePrice" double precision DEFAULT 0,
"currentValue" double precision,
"product" bigint,
"vendor" bigint,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "customerspaces" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "customerspaces_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"tenant" bigint NOT NULL,
"customer" bigint NOT NULL,
"spaceNumber" text NOT NULL,
"parentSpace" bigint,
"infoData" jsonb DEFAULT '{"zip":"","city":"","streetNumber":""}'::jsonb NOT NULL,
"description" text,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
CREATE TABLE "entitybankaccounts" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "entitybankaccounts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"iban_encrypted" jsonb NOT NULL,
"bic_encrypted" jsonb NOT NULL,
"bank_name_encrypted" jsonb NOT NULL,
"description" text,
"updated_at" timestamp with time zone,
"updated_by" uuid,
"archived" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "memberrelations" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "memberrelations_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"tenant" bigint NOT NULL,
"type" text NOT NULL,
"billingInterval" text NOT NULL,
"billingAmount" double precision DEFAULT 0 NOT NULL,
"archived" boolean DEFAULT false NOT NULL,
"updated_at" timestamp with time zone,
"updated_by" uuid
);
--> statement-breakpoint
ALTER TABLE "tenants" ALTER COLUMN "numberRanges" SET DEFAULT '{"vendors":{"prefix":"","suffix":"","nextNumber":10000},"customers":{"prefix":"","suffix":"","nextNumber":10000},"products":{"prefix":"AT-","suffix":"","nextNumber":1000},"quotes":{"prefix":"AN-","suffix":"","nextNumber":1000},"confirmationOrders":{"prefix":"AB-","suffix":"","nextNumber":1000},"invoices":{"prefix":"RE-","suffix":"","nextNumber":1000},"spaces":{"prefix":"LP-","suffix":"","nextNumber":1000},"customerspaces":{"prefix":"KLP-","suffix":"","nextNumber":1000},"inventoryitems":{"prefix":"IA-","suffix":"","nextNumber":1000},"customerinventoryitems":{"prefix":"KIA-","suffix":"","nextNumber":1000},"projects":{"prefix":"PRJ-","suffix":"","nextNumber":1000},"costcentres":{"prefix":"KST-","suffix":"","nextNumber":1000}}'::jsonb;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "contracttype" bigint;--> statement-breakpoint
ALTER TABLE "contracts" ADD COLUMN "billingInterval" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "customTaxType" text;--> statement-breakpoint
ALTER TABLE "customers" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerspace" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "customerinventoryitem" bigint;--> statement-breakpoint
ALTER TABLE "historyitems" ADD COLUMN "memberrelation" bigint;--> statement-breakpoint
ALTER TABLE "services" ADD COLUMN "priceUpdateLocked" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracttypes" ADD CONSTRAINT "contracttypes_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_product_products_id_fk" FOREIGN KEY ("product") REFERENCES "public"."products"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_vendor_vendors_id_fk" FOREIGN KEY ("vendor") REFERENCES "public"."vendors"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerinventoryitems" ADD CONSTRAINT "customerinventoryitems_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_customer_customers_id_fk" FOREIGN KEY ("customer") REFERENCES "public"."customers"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_parentSpace_customerspaces_id_fk" FOREIGN KEY ("parentSpace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customerspaces" ADD CONSTRAINT "customerspaces_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entitybankaccounts" ADD CONSTRAINT "entitybankaccounts_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_tenant_tenants_id_fk" FOREIGN KEY ("tenant") REFERENCES "public"."tenants"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberrelations" ADD CONSTRAINT "memberrelations_updated_by_auth_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."auth_users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "contracts" ADD CONSTRAINT "contracts_contracttype_contracttypes_id_fk" FOREIGN KEY ("contracttype") REFERENCES "public"."contracttypes"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "customers" ADD CONSTRAINT "customers_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerspace_customerspaces_id_fk" FOREIGN KEY ("customerspace") REFERENCES "public"."customerspaces"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_customerinventoryitem_customerinventoryitems_id_fk" FOREIGN KEY ("customerinventoryitem") REFERENCES "public"."customerinventoryitems"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "historyitems" ADD CONSTRAINT "historyitems_memberrelation_memberrelations_id_fk" FOREIGN KEY ("memberrelation") REFERENCES "public"."memberrelations"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "accounts" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "accountChart" text DEFAULT 'skr03' NOT NULL;

View File

@@ -0,0 +1,3 @@
ALTER TABLE "createddocuments"
ALTER COLUMN "customSurchargePercentage" TYPE double precision
USING "customSurchargePercentage"::double precision;

View File

@@ -113,6 +113,27 @@
"when": 1773000700000,
"tag": "0015_wise_memberrelation_history",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1773000800000,
"tag": "0016_fix_memberrelation_column_usage",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1771704862789,
"tag": "0017_slow_the_hood",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1773000900000,
"tag": "0018_account_chart",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@ export const accounts = pgTable("accounts", {
number: text("number").notNull(),
label: text("label").notNull(),
accountChart: text("accountChart").notNull().default("skr03"),
description: text("description"),
})

View File

@@ -6,6 +6,7 @@ import {
jsonb,
boolean,
smallint,
doublePrecision,
uuid,
} from "drizzle-orm/pg-core"
@@ -96,7 +97,7 @@ export const createddocuments = pgTable("createddocuments", {
taxType: text("taxType"),
customSurchargePercentage: smallint("customSurchargePercentage")
customSurchargePercentage: doublePrecision("customSurchargePercentage")
.notNull()
.default(0),

View File

@@ -10,6 +10,7 @@ import {
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { memberrelations } from "./memberrelations"
export const customers = pgTable(
"customers",
@@ -63,6 +64,7 @@ export const customers = pgTable(
customPaymentType: text("custom_payment_type"), // ENUM payment_types separat?
customTaxType: text("customTaxType"),
memberrelation: bigint("memberrelation", { mode: "number" }).references(() => memberrelations.id),
}
)

View File

@@ -74,6 +74,48 @@ export const tenants = pgTable(
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}),
ownFields: jsonb("ownFields"),
@@ -94,6 +136,7 @@ export const tenants = pgTable(
projects: { prefix: "PRJ-", suffix: "", nextNumber: 1000 },
costcentres: { prefix: "KST-", suffix: "", nextNumber: 1000 },
}),
accountChart: text("accountChart").notNull().default("skr03"),
standardEmailForInvoices: text("standardEmailForInvoices"),

View File

@@ -6,6 +6,6 @@ export default defineConfig({
schema: "./db/schema",
out: "./db/migrations",
dbCredentials: {
url: secrets.DATABASE_URL,
url: secrets.DATABASE_URL || "postgres://postgres:wJw7aNpEBJdcxgoct6GXNpvY4Cn6ECqu@fedeo-db-001.vpn.internal:5432/fedeo",
},
})

View File

@@ -10,7 +10,9 @@
"build": "tsc",
"start": "node dist/src/index.js",
"schema:index": "ts-node scripts/generate-schema-index.ts",
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts"
"bankcodes:update": "tsx scripts/generate-de-bank-codes.ts",
"members:import:csv": "tsx scripts/import-members-csv.ts",
"accounts:import:skr42": "ts-node scripts/import-skr42-accounts.ts"
},
"repository": {
"type": "git",

View File

@@ -38,6 +38,11 @@ function normalizeUuid(value: unknown): string | null {
return trimmed.length ? trimmed : null;
}
function sanitizeCompositionRows(value: unknown): CompositionRow[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is CompositionRow => !!entry && typeof entry === "object");
}
export async function recalculateServicePricesForTenant(server: FastifyInstance, tenantId: number, updatedBy?: string | null) {
const [services, products, hourrates] = await Promise.all([
server.db.select().from(schema.services).where(eq(schema.services.tenant, tenantId)),
@@ -88,94 +93,111 @@ export async function recalculateServicePricesForTenant(server: FastifyInstance,
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition: Array.isArray(service.materialComposition) ? service.materialComposition as CompositionRow[] : [],
personalComposition: Array.isArray(service.personalComposition) ? service.personalComposition as CompositionRow[] : [],
materialComposition: sanitizeCompositionRows(service.materialComposition),
personalComposition: sanitizeCompositionRows(service.personalComposition),
};
memo.set(serviceId, lockedResult);
return lockedResult;
}
stack.add(serviceId);
try {
const materialComposition = sanitizeCompositionRows(service.materialComposition);
const personalComposition = sanitizeCompositionRows(service.personalComposition);
const hasMaterialComposition = materialComposition.length > 0;
const hasPersonalComposition = personalComposition.length > 0;
const materialComposition: CompositionRow[] = Array.isArray(service.materialComposition)
? (service.materialComposition as CompositionRow[])
: [];
const personalComposition: CompositionRow[] = Array.isArray(service.personalComposition)
? (service.personalComposition as CompositionRow[])
: [];
let materialTotal = 0;
let materialPurchaseTotal = 0;
const normalizedMaterialComposition = materialComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const productId = normalizeId(entry.product);
const childServiceId = normalizeId(entry.service);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
// Ohne Zusammensetzung keine automatische Überschreibung:
// manuell gepflegte Preise sollen erhalten bleiben.
if (!hasMaterialComposition && !hasPersonalComposition) {
const manualResult = {
sellingTotal: getJsonNumber(service.sellingPriceComposed, "total") || toNumber(service.sellingPrice),
purchaseTotal: getJsonNumber(service.purchasePriceComposed, "total"),
materialTotal: getJsonNumber(service.sellingPriceComposed, "material"),
materialPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "material"),
workerTotal: getJsonNumber(service.sellingPriceComposed, "worker"),
workerPurchaseTotal: getJsonNumber(service.purchasePriceComposed, "worker"),
materialComposition,
personalComposition,
};
memo.set(serviceId, manualResult);
return manualResult;
}
materialTotal += quantity * sellingPrice;
materialPurchaseTotal += quantity * purchasePrice;
let materialTotal = 0;
let materialPurchaseTotal = 0;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const normalizedMaterialComposition = materialComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const productId = normalizeId(entry.product);
const childServiceId = normalizeId(entry.service);
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
if (productId) {
const product = productMap.get(productId);
sellingPrice = toNumber(product?.selling_price);
purchasePrice = toNumber(product?.purchase_price);
} else if (childServiceId) {
const child = calculateService(childServiceId);
sellingPrice = toNumber(child.sellingTotal);
purchasePrice = toNumber(child.purchaseTotal);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
materialTotal += quantity * sellingPrice;
materialPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
let workerTotal = 0;
let workerPurchaseTotal = 0;
const normalizedPersonalComposition = personalComposition.map((entry) => {
const quantity = toNumber(entry.quantity);
const hourrateId = normalizeUuid(entry.hourrate);
let sellingPrice = toNumber(entry.price);
let purchasePrice = toNumber(entry.purchasePrice);
if (hourrateId) {
const hourrate = hourrateMap.get(hourrateId);
if (hourrate) {
sellingPrice = toNumber(hourrate.sellingPrice);
purchasePrice = toNumber(hourrate.purchase_price);
}
}
workerTotal += quantity * sellingPrice;
workerPurchaseTotal += quantity * purchasePrice;
return {
...entry,
price: round2(sellingPrice),
purchasePrice: round2(purchasePrice),
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
});
const result = {
sellingTotal: round2(materialTotal + workerTotal),
purchaseTotal: round2(materialPurchaseTotal + workerPurchaseTotal),
materialTotal: round2(materialTotal),
materialPurchaseTotal: round2(materialPurchaseTotal),
workerTotal: round2(workerTotal),
workerPurchaseTotal: round2(workerPurchaseTotal),
materialComposition: normalizedMaterialComposition,
personalComposition: normalizedPersonalComposition,
};
memo.set(serviceId, result);
stack.delete(serviceId);
return result;
memo.set(serviceId, result);
return result;
} finally {
stack.delete(serviceId);
}
};
for (const service of services) {

View File

@@ -58,8 +58,6 @@ const queryConfigPlugin: FastifyPluginAsync<QueryConfigPluginOptions> = async (
const query = req.query as Record<string, any>
console.log(query)
// Pagination deaktivieren?
const disablePagination =
query.noPagination === 'true' ||

View File

@@ -94,6 +94,7 @@ export default async function adminRoutes(server: FastifyInstance) {
short: tenants.short,
locked: tenants.locked,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
extraModules: tenants.extraModules,
})
.from(authTenantUsers)

View File

@@ -51,9 +51,11 @@ export default async function meRoutes(server: FastifyInstance) {
name: tenants.name,
short: tenants.short,
locked: tenants.locked,
features: tenants.features,
extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo,
numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart,
dokuboxkey: tenants.dokuboxkey,
standardEmailForInvoices: tenants.standardEmailForInvoices,
standardPaymentDays: tenants.standardPaymentDays,

View File

@@ -59,6 +59,44 @@ const parseId = (value: string) => {
}
export default async function resourceHistoryRoutes(server: FastifyInstance) {
server.get("/history", {
schema: {
tags: ["History"],
summary: "Get all history entries for the active tenant",
},
}, async (req: any) => {
const data = await server.db
.select()
.from(historyitems)
.where(eq(historyitems.tenant, req.user?.tenant_id))
.orderBy(asc(historyitems.createdAt));
const userIds = Array.from(
new Set(data.map((item) => item.createdBy).filter(Boolean))
) as string[];
const profiles = userIds.length > 0
? await server.db
.select()
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, req.user?.tenant_id),
inArray(authProfiles.user_id, userIds)
))
: [];
const profileByUserId = new Map(
profiles.map((profile) => [profile.user_id, profile])
);
return data.map((historyitem) => ({
...historyitem,
created_at: historyitem.createdAt,
created_by: historyitem.createdBy,
created_by_profile: historyitem.createdBy ? profileByUserId.get(historyitem.createdBy) || null : null,
}));
});
server.get<{
Params: { resource: string; id: string }
}>("/resource/:resource/:id/history", {

View File

@@ -586,6 +586,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>;
const config = resourceConfig[resource];
const table = config.table;
@@ -656,6 +659,9 @@ export default async function resourceRoutes(server: FastifyInstance) {
server.put("/resource/:resource/:id", async (req, reply) => {
try {
const { resource, id } = req.params as { resource: string; id: string }
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id

View File

@@ -1,9 +1,9 @@
import { FastifyInstance } from "fastify"
import { asc, desc } from "drizzle-orm"
import { asc, desc, eq } from "drizzle-orm"
import { sortData } from "../utils/sort"
// Schema imports
import { accounts, units,countrys } from "../../db/schema"
import { accounts, units, countrys, tenants } from "../../db/schema"
const TABLE_MAP: Record<string, any> = {
accounts,
@@ -40,6 +40,44 @@ export default async function resourceRoutesSpecial(server: FastifyInstance) {
// Wir geben IMMER alle Spalten zurück → kompatibel zum Frontend
// ---------------------------------------
if (resource === "accounts") {
const [tenant] = await server.db
.select({
accountChart: tenants.accountChart,
})
.from(tenants)
.where(eq(tenants.id, Number(req.user.tenant_id)))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
let data
if (sort && (accounts as any)[sort]) {
const col = (accounts as any)[sort]
data = ascQuery === "true"
? await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(asc(col))
: await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
.orderBy(desc(col))
} else {
data = await server.db
.select()
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart))
}
return sortData(
data,
sort as any,
ascQuery === "true"
)
}
let query = server.db.select().from(table)
// ---------------------------------------

View File

@@ -11,7 +11,7 @@ import { s3 } from "./s3";
import { secrets } from "./secrets";
// Drizzle schema
import { vendors, accounts } from "../../db/schema";
import { vendors, accounts, tenants } from "../../db/schema";
import {eq} from "drizzle-orm";
let openai: OpenAI | null = null;
@@ -163,13 +163,22 @@ export const getInvoiceDataFromGPT = async function (
.from(vendors)
.where(eq(vendors.tenant,tenantId));
const [tenant] = await server.db
.select({ accountChart: tenants.accountChart })
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1)
const activeAccountChart = tenant?.accountChart || "skr03"
const accountList = await server.db
.select({
id: accounts.id,
label: accounts.label,
number: accounts.number,
})
.from(accounts);
.from(accounts)
.where(eq(accounts.accountChart, activeAccountChart));
// ---------------------------------------------------------
// 4) GPT ANALYSIS

View File

@@ -1,37 +1,70 @@
version: "3"
services:
web:
image: reg.federspiel.software/fedeo/software:beta
frontend:
image: git.federspiel.tech/flfeders/fedeo/frontend:dev
restart: always
environment:
- INFISICAL_CLIENT_ID=abc
- INFISICAL_CLIENT_SECRET=abc
- NUXT_PUBLIC_API_BASE=https://app.fedeo.de/backend
- NUXT_PUBLIC_PDF_LICENSE=eyJkYXRhIjoiZXlKMElqb2laR1YyWld4dmNHVnlJaXdpWVhaMUlqb3hOemt3TmpNNU9UazVMQ0prYlNJNkltRndjQzVtWldSbGJ5NWtaU0lzSW00aU9pSXpOemt3Wm1Vek5UazBZbVU0TlRRNElpd2laWGh3SWpveE56a3dOak01T1RrNUxDSmtiWFFpT2lKemNHVmphV1pwWXlJc0luQWlPaUoyYVdWM1pYSWlmUT09Iiwic2lnbmF0dXJlIjoicWU4K0ZxQUJDNUp5bEJUU094Vkd5RTJMbk9UNmpyc2EyRStsN2tNNWhkM21KK2ZvVjYwaTFKeFdhZGtqSDRNWXZxQklMc0dpdWh5d2pMbUFjRHZuWGxOcTRMcXFLRm53dzVtaG1LK3lTeDRXbzVaS1loK1VZdFBzWUZjV3oyUHVGMmJraGJrVjJ6RzRlTGtRU09wdmJKY3JUZU1rN0N1VkN6Q1UraHF5T0ZVVXllWnRmaHlmcWswZEFFL0RMR1hvTDFSQXFjNkNkYU9FTDRTdC9Idy9DQnFieTE2aisvT3RxQUlLcy9NWTR6SVk3RTI3bWo4RUx5VjhXNkdXNXhqc0VUVzNKN0RRMUVlb3RhVlNLT29kc3pVRlhUYzVlbHVuSm04ZlcwM1ErMUhtSnpmWGoyS1dwM1dnamJDazZYSHozamFML2lOdUYvZFZNaWYvc2FoR3NnPT0ifQ==
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3000"
# Middlewares
- "traefik.http.middlewares.fedeo-frontend-redirect-web-secure.redirectscheme.scheme=https"
# Web Entrypoint
- "traefik.http.routers.fedeo-frontend.middlewares=fedeo-frontend-redirect-web-secure"
- "traefik.http.routers.fedeo-frontend.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-frontend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/`)"
- "traefik.http.routers.fedeo-frontend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-frontend-secure.tls.certresolver=mytlschallenge"
backend:
image: reg.federspiel.software/fedeo/backend:main
image: git.federspiel.tech/flfeders/fedeo/backend:dev
restart: always
environment:
- NUXT_PUBLIC_API_BASE=
- NUXT_PUBLIC_PDF_LICENSE=
db:
image: postgres
restart: always
shm_size: 128mb
environment:
POSTGRES_PASSWORD: abc
POSTGRES_USER: sandelcom
POSTGRES_DB: sensorfy
volumes:
- ./pg-data:/var/lib/postgresql/data
ports:
- "5432:5432"
- INFISICAL_CLIENT_ID=a6838bd6-9983-4bf4-9be2-ace830b9abdf
- INFISICAL_CLIENT_SECRET=4e3441acc0adbffd324aa50e668a95a556a3f55ec6bb85954e176e35a3392003
- NODE_ENV=production
networks:
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.port=3100"
# Middlewares
- "traefik.http.middlewares.fedeo-backend-redirect-web-secure.redirectscheme.scheme=https"
- "traefik.http.middlewares.fedeo-backend-strip.stripprefix.prefixes=/backend"
# Web Entrypoint
- "traefik.http.routers.fedeo-backend.middlewares=fedeo-backend-redirect-web-secure"
- "traefik.http.routers.fedeo-backend.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend.entrypoints=web"
# Web Secure Entrypoint
- "traefik.http.routers.fedeo-backend-secure.rule=Host(`app.fedeo.de`) && PathPrefix(`/backend`)"
- "traefik.http.routers.fedeo-backend-secure.entrypoints=web-secured" #
- "traefik.http.routers.fedeo-backend-secure.tls.certresolver=mytlschallenge"
- "traefik.http.routers.fedeo-backend-secure.middlewares=fedeo-backend-strip"
# db:
# image: postgres
# restart: always
# shm_size: 128mb
# environment:
# POSTGRES_PASSWORD: abc
# POSTGRES_USER: sandelcom
# POSTGRES_DB: sensorfy
# volumes:
# - ./pg-data:/var/lib/postgresql/data
# ports:
# - "5432:5432"
traefik:
image: traefik:v2.2
image: traefik:v2.11
restart: unless-stopped
container_name: traefik
command:
- "--api.insecure=false"
- "--api.dashboard=true"
- "--api.dashboard=false"
- "--api.debug=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
@@ -43,19 +76,18 @@ services:
- "--accesslog.bufferingsize=5000"
- "--accesslog.fields.defaultMode=keep"
- "--accesslog.fields.headers.defaultMode=keep"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" # <== Enable TLS-ALPN-01 to generate and renew ACME certs
- "--certificatesresolvers.mytlschallenge.acme.email=info@sandelcom.de" # <== Setting email for certs
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json" # <== Defining acme file to store cert information
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true" #
- "--certificatesresolvers.mytlschallenge.acme.email=moin@fedeo.de"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
- 80:80
- 8080:8080
- 443:443
volumes:
- "./traefik/letsencrypt:/letsencrypt" # <== Volume for certs (TLS)
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/logs:/logs"
labels:
#### Labels define the behavior and rules of the traefik proxy for this container ####
- "traefik.enable=true" # <== Enable traefik on itself to view dashboard and assign subdomain to view it
- "traefik.http.routers.api.rule=Host(`srv1.drinkingteam.de`)" # <== Setting the domain for the dashboard
- "traefik.http.routers.api.service=api@internal" # <== Enabling the api to be a service to access
networks:
- traefik
networks:
traefik:
external: false

View File

@@ -3,11 +3,13 @@ import dayjs from "dayjs"
const props = defineProps({
type: {
type: String,
required: true
required: false,
default: null
},
elementId: {
type: String,
required: true
required: false,
default: null
},
renderHeadline: {
type: Boolean,
@@ -25,13 +27,11 @@ const items = ref([])
const platform = ref("default")
const setup = async () => {
if(props.type && props.elementId){
items.value = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`)
} /*else {
}*/
} else {
items.value = await useNuxtApp().$api(`/api/history`)
}
}
setup()
@@ -43,6 +43,10 @@ const addHistoryItemData = ref({
})
const addHistoryItem = async () => {
if (!props.type || !props.elementId) {
toast.add({ title: "Im zentralen Logbuch können keine direkten Einträge erstellt werden." })
return
}
const res = await useNuxtApp().$api(`/api/resource/${props.type}/${props.elementId}/history`, {
method: "POST",

View File

@@ -15,8 +15,241 @@ const showMembersNav = computed(() => {
const showMemberRelationsNav = computed(() => {
return tenantExtraModules.value.includes("verein") && has("members")
})
const tenantFeatures = computed(() => auth.activeTenantData?.features || {})
const featureEnabled = (key) => tenantFeatures.value?.[key] !== false
const links = computed(() => {
const organisationChildren = [
has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
} : null,
featureEnabled("wiki") ? {
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
} : null,
].filter(Boolean)
const documentChildren = [
featureEnabled("files") ? {
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
} : null,
featureEnabled("createdletters") ? {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
} : null,
featureEnabled("documentboxes") ? {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
} : null,
].filter(Boolean)
const communicationChildren = [
featureEnabled("helpdesk") ? {
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
} : null,
featureEnabled("email") ? {
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
} : null,
].filter(Boolean)
const contactsChildren = [
showMembersNav.value && featureEnabled("members") ? {
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
} : null,
has("customers") && featureEnabled("customers") ? {
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
} : null,
has("vendors") && featureEnabled("vendors") ? {
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
} : null,
has("contacts") && featureEnabled("contactsList") ? {
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
} : null,
].filter(Boolean)
const staffChildren = [
featureEnabled("staffTime") ? {
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
} : null,
].filter(Boolean)
const accountingChildren = [
featureEnabled("createDocument") ? {
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("serialInvoice") ? {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("incomingInvoices") ? {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("costcentres") ? {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
} : null,
featureEnabled("accounts") ? {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
} : null,
featureEnabled("ownaccounts") ? {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
} : null,
featureEnabled("banking") ? {
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
} : null,
].filter(Boolean)
const inventoryChildren = [
has("spaces") && featureEnabled("spaces") ? {
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
} : null,
has("inventoryitems") && featureEnabled("customerspaces") ? {
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
} : null,
has("inventoryitems") && featureEnabled("customerinventoryitems") ? {
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
} : null,
has("inventoryitems") && featureEnabled("inventoryitems") ? {
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
} : null,
has("inventoryitems") && featureEnabled("inventoryitemgroups") ? {
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
} : null,
].filter(Boolean)
const masterDataChildren = [
has("products") && featureEnabled("products") ? {
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
} : null,
has("productcategories") && featureEnabled("productcategories") ? {
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
} : null,
has("services") && featureEnabled("services") ? {
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
} : null,
has("servicecategories") && featureEnabled("servicecategories") ? {
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
} : null,
showMemberRelationsNav.value && featureEnabled("memberrelations") ? {
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
} : null,
featureEnabled("staffProfiles") ? {
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("hourrates") ? {
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
} : null,
featureEnabled("projecttypes") ? {
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("contracttypes") ? {
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
} : null,
has("vehicles") && featureEnabled("vehicles") ? {
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
} : null,
].filter(Boolean)
const settingsChildren = [
featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsEmailAccounts") ? {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
} : null,
featureEnabled("settingsBanking") ? {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
} : null,
featureEnabled("settingsTexttemplates") ? {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
} : null,
featureEnabled("settingsTenant") ? {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
} : null,
featureEnabled("export") ? {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
} : null,
].filter(Boolean)
return [
...(auth.profile?.pinned_on_navigation || []).map(pin => {
if (pin.type === "external") {
@@ -37,291 +270,89 @@ const links = computed(() => {
}
}),
{
featureEnabled("dashboard") ? {
id: 'dashboard',
label: "Dashboard",
to: "/",
icon: "i-heroicons-home"
},
{
} : null,
featureEnabled("historyitems") ? {
id: 'historyitems',
label: "Logbuch",
to: "/historyitems",
icon: "i-heroicons-book-open",
disabled: true
},
{
icon: "i-heroicons-book-open"
} : null,
...(organisationChildren.length > 0 ? [{
label: "Organisation",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
...has("tasks") ? [{
label: "Aufgaben",
to: "/tasks",
icon: "i-heroicons-rectangle-stack"
}] : [],
...true ? [{
label: "Wiki",
to: "/wiki",
icon: "i-heroicons-book-open"
}] : [],
]
},
{
children: organisationChildren
}] : []),
...(documentChildren.length > 0 ? [{
label: "Dokumente",
icon: "i-heroicons-rectangle-stack",
defaultOpen: false,
children: [
{
label: "Dateien",
to: "/files",
icon: "i-heroicons-document"
}, {
label: "Anschreiben",
to: "/createdletters",
icon: "i-heroicons-document",
disabled: true
}, {
label: "Boxen",
to: "/standardEntity/documentboxes",
icon: "i-heroicons-archive-box",
disabled: true
},
]
},
{
children: documentChildren
}] : []),
...(communicationChildren.length > 0 ? [{
label: "Kommunikation",
icon: "i-heroicons-megaphone",
defaultOpen: false,
children: [
{
label: "Helpdesk",
to: "/helpdesk",
icon: "i-heroicons-chat-bubble-left-right",
disabled: true
},
{
label: "E-Mail",
to: "/email/new",
icon: "i-heroicons-envelope",
disabled: true
}
]
},
...(has("customers") || has("vendors") || has("contacts") || showMembersNav.value) ? [{
children: communicationChildren
}] : []),
...(contactsChildren.length > 0 ? [{
label: "Kontakte",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...showMembersNav.value ? [{
label: "Mitglieder",
to: "/standardEntity/members",
icon: "i-heroicons-user-group"
}] : [],
...has("customers") ? [{
label: "Kunden",
to: "/standardEntity/customers",
icon: "i-heroicons-user-group"
}] : [],
...has("vendors") ? [{
label: "Lieferanten",
to: "/standardEntity/vendors",
icon: "i-heroicons-truck"
}] : [],
...has("contacts") ? [{
label: "Ansprechpartner",
to: "/standardEntity/contacts",
icon: "i-heroicons-user-group"
}] : [],
]
}] : [],
{
children: contactsChildren
}] : []),
...(staffChildren.length > 0 ? [{
label: "Mitarbeiter",
defaultOpen: false,
icon: "i-heroicons-user-group",
children: [
...true ? [{
label: "Zeiten",
to: "/staff/time",
icon: "i-heroicons-clock",
}] : [],
]
},
...[{
children: staffChildren
}] : []),
...(accountingChildren.length > 0 ? [{
label: "Buchhaltung",
defaultOpen: false,
icon: "i-heroicons-chart-bar-square",
children: [
{
label: "Ausgangsbelege",
to: "/createDocument",
icon: "i-heroicons-document-text"
}, {
label: "Serienvorlagen",
to: "/createDocument/serialInvoice",
icon: "i-heroicons-document-text"
}, {
label: "Eingangsbelege",
to: "/incomingInvoices",
icon: "i-heroicons-document-text",
}, {
label: "Kostenstellen",
to: "/standardEntity/costcentres",
icon: "i-heroicons-document-currency-euro"
}, {
label: "Buchungskonten",
to: "/accounts",
icon: "i-heroicons-document-text",
}, {
label: "zusätzliche Buchungskonten",
to: "/standardEntity/ownaccounts",
icon: "i-heroicons-document-text"
},
{
label: "Bank",
to: "/banking",
icon: "i-heroicons-document-text",
},
]
}],
...has("inventory") ? [{
children: accountingChildren
}] : []),
...(inventoryChildren.length > 0 ? [{
label: "Lager",
icon: "i-heroicons-puzzle-piece",
defaultOpen: false,
children: [
...has("spaces") ? [{
label: "Lagerplätze",
to: "/standardEntity/spaces",
icon: "i-heroicons-square-3-stack-3d"
}] : [],
...has("inventoryitems") ? [{
label: "Kundenlagerplätze",
to: "/standardEntity/customerspaces",
icon: "i-heroicons-squares-plus"
}] : [],
...has("inventoryitems") ? [{
label: "Kundeninventar",
to: "/standardEntity/customerinventoryitems",
icon: "i-heroicons-qr-code"
}] : [],
...has("inventoryitems") ? [{
label: "Inventar",
to: "/standardEntity/inventoryitems",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("inventoryitems") ? [{
label: "Inventargruppen",
to: "/standardEntity/inventoryitemgroups",
icon: "i-heroicons-puzzle-piece"
}] : [],
]
}] : [],
{
children: inventoryChildren
}] : []),
...(masterDataChildren.length > 0 ? [{
label: "Stammdaten",
defaultOpen: false,
icon: "i-heroicons-clipboard-document",
children: [
...has("products") ? [{
label: "Artikel",
to: "/standardEntity/products",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("productcategories") ? [{
label: "Artikelkategorien",
to: "/standardEntity/productcategories",
icon: "i-heroicons-puzzle-piece"
}] : [],
...has("services") ? [{
label: "Leistungen",
to: "/standardEntity/services",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...has("servicecategories") ? [{
label: "Leistungskategorien",
to: "/standardEntity/servicecategories",
icon: "i-heroicons-wrench-screwdriver"
}] : [],
...showMemberRelationsNav.value ? [{
label: "Mitgliedsverhältnisse",
to: "/standardEntity/memberrelations",
icon: "i-heroicons-identification"
}] : [],
{
label: "Mitarbeiter",
to: "/staff/profiles",
icon: "i-heroicons-user-group"
},
{
label: "Stundensätze",
to: "/standardEntity/hourrates",
icon: "i-heroicons-user-group"
},
{
label: "Projekttypen",
to: "/projecttypes",
icon: "i-heroicons-clipboard-document-list",
},
{
label: "Vertragstypen",
to: "/standardEntity/contracttypes",
icon: "i-heroicons-document-duplicate",
},
...has("vehicles") ? [{
label: "Fahrzeuge",
to: "/standardEntity/vehicles",
icon: "i-heroicons-truck"
}] : [],
]
},
children: masterDataChildren
}] : []),
...has("projects") ? [{
...(has("projects") && featureEnabled("projects")) ? [{
label: "Projekte",
to: "/standardEntity/projects",
icon: "i-heroicons-clipboard-document-check"
}] : [],
...has("contracts") ? [{
...(has("contracts") && featureEnabled("contracts")) ? [{
label: "Verträge",
to: "/standardEntity/contracts",
icon: "i-heroicons-clipboard-document"
}] : [],
...has("plants") ? [{
...(has("plants") && featureEnabled("plants")) ? [{
label: "Objekte",
to: "/standardEntity/plants",
icon: "i-heroicons-clipboard-document"
}] : [],
{
...(settingsChildren.length > 0 ? [{
label: "Einstellungen",
defaultOpen: false,
icon: "i-heroicons-cog-8-tooth",
children: [
{
label: "Nummernkreise",
to: "/settings/numberRanges",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "E-Mail Konten",
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
}, {
label: "Bankkonten",
to: "/settings/banking",
icon: "i-heroicons-currency-euro",
}, {
label: "Textvorlagen",
to: "/settings/texttemplates",
icon: "i-heroicons-clipboard-document-list",
}, {
label: "Firmeneinstellungen",
to: "/settings/tenant",
icon: "i-heroicons-building-office",
}, {
label: "Export",
to: "/export",
icon: "i-heroicons-clipboard-document-list"
}
]
},
]
children: settingsChildren
}] : []),
].filter(Boolean)
})
const accordionItems = computed(() =>

View File

@@ -6,8 +6,20 @@ const props = defineProps({
default: {}
}
})
const descriptionText = computed(() => {
const description = props.row?.description
if (!description) return ""
if (typeof description === "string") return description
if (typeof description === "object") {
if (typeof description.text === "string" && description.text.trim().length) {
return description.text
}
}
return String(description)
})
</script>
<template>
<div v-if="props.row.description" v-html="props.row.description.html"/>
<div v-if="descriptionText">{{ descriptionText }}</div>
</template>

View File

@@ -11,18 +11,22 @@ const relations = ref([])
const normalizeId = (value) => {
if (value === null || value === undefined || value === "") return null
if (typeof value === "object") return normalizeId(value.id)
const parsed = Number(value)
return Number.isNaN(parsed) ? String(value) : parsed
}
const relationLabel = computed(() => {
const id = normalizeId(props.row?.infoData?.memberrelation)
const relation = props.row?.memberrelation
if (relation && typeof relation === "object" && relation.type) return relation.type
const id = normalizeId(relation)
if (!id) return ""
return relations.value.find((i) => normalizeId(i.id) === id)?.type || ""
})
const relationId = computed(() => {
return normalizeId(props.row?.infoData?.memberrelation)
return normalizeId(props.row?.memberrelation)
})
const loadRelations = async () => {

View File

@@ -1,239 +1,279 @@
<script setup>
import customParseFormat from "dayjs/plugin/customParseFormat";
import dayjs from "dayjs";
import { Line } from "vue-chartjs";
dayjs.extend(customParseFormat)
const tempStore = useTempStore()
let incomeData = ref({})
let expenseData = ref({})
const amountMode = ref("net")
const granularity = ref("year")
const selectedYear = ref(dayjs().year())
const selectedMonth = ref(dayjs().month() + 1)
const setup = async () => {
let incomeRawData = (await useEntities("createddocuments").select()).filter(i => i.state === "Gebucht" && ['invoices','advanceInvoices','cancellationInvoices'].includes(i.type))
const incomeDocuments = ref([])
const expenseInvoices = ref([])
let incomeRawFilteredData = incomeRawData.filter(x => x.state === 'Gebucht' && incomeRawData.find(i => i.linkedDocument && i.linkedDocument.id === x.id && i.type === 'cancellationInvoices') && ['invoices','advanceInvoices'].includes(row.type))
const granularityOptions = [
{ label: "Jahr", value: "year" },
{ label: "Monat", value: "month" }
]
const monthOptions = [
{ label: "Januar", value: 1 },
{ label: "Februar", value: 2 },
{ label: "März", value: 3 },
{ label: "April", value: 4 },
{ label: "Mai", value: 5 },
{ label: "Juni", value: 6 },
{ label: "Juli", value: 7 },
{ label: "August", value: 8 },
{ label: "September", value: 9 },
{ label: "Oktober", value: 10 },
{ label: "November", value: 11 },
{ label: "Dezember", value: 12 }
]
let expenseRawData =(await useEntities("incominginvoices").select())
let withoutInvoiceRawData = (await useEntities("statementallocations").select()).filter(i => i.account)
const normalizeMode = (value) => value === "gross" ? "gross" : "net"
const normalizeGranularity = (value) => value === "month" ? "month" : "year"
let withoutInvoiceRawDataExpenses = []
let withoutInvoiceRawDataIncomes = []
watch(
() => tempStore.settings?.dashboardIncomeExpenseView,
(storedView) => {
const legacyMode = tempStore.settings?.dashboardIncomeExpenseMode
withoutInvoiceRawData.forEach(i => {
if(i.amount > 0) {
withoutInvoiceRawDataIncomes.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
} else if(i.amount < 0) {
withoutInvoiceRawDataExpenses.push({
id: i.id,
date: dayjs(i.created_at).format("DD-MM-YY"),
amount: Math.abs(i.amount),
bs_id: i.bs_id
})
}
amountMode.value = normalizeMode(storedView?.amountMode || legacyMode)
granularity.value = normalizeGranularity(storedView?.granularity)
const nextYear = Number(storedView?.year)
const nextMonth = Number(storedView?.month)
selectedYear.value = Number.isFinite(nextYear) ? nextYear : dayjs().year()
selectedMonth.value = Number.isFinite(nextMonth) && nextMonth >= 1 && nextMonth <= 12
? nextMonth
: dayjs().month() + 1
},
{ immediate: true }
)
watch([amountMode, granularity, selectedYear, selectedMonth], () => {
tempStore.modifySettings("dashboardIncomeExpenseView", {
amountMode: amountMode.value,
granularity: granularity.value,
year: selectedYear.value,
month: selectedMonth.value
})
/*withoutInvoiceRawDataExpenses.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
withoutInvoiceRawDataIncomes.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})*/
expenseRawData = expenseRawData.filter(i => i.date).map(i => {
let amount = 0
i.accounts.forEach(a => {
amount += a.amountNet
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.date).format("DD-MM-YY"),
amount
}
})
expenseRawData.forEach(i => {
expenseData.value[i.date] ? expenseData.value[i.date] = Number((expenseData.value[i.date] + i.amount).toFixed(2)) : expenseData.value[i.date] = i.amount
})
let expenseMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(expenseMonths).forEach(month => {
let dates = Object.keys(expenseData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(expenseMonths[month]){
expenseMonths[month] = Number((expenseMonths[month] + expenseData.value[date]).toFixed(2))
} else {
expenseMonths[month] = expenseData.value[date]
}
})
})
expenseData.value = expenseMonths
incomeRawData = incomeRawData.map(i => {
let amount = 0
i.rows.forEach(r => {
if(r.mode !== "pagebreak" && r.mode !== "title" && r.mode !== "text"){
amount += r.price * r.quantity * (1 - r.discountPercent/100)
}
})
amount = Number(amount.toFixed(2))
return {
id: i.id,
date: dayjs(i.documentDate).format("DD-MM-YY"),
amount
}
})
incomeRawData.forEach(i => {
incomeData.value[i.date] ? incomeData.value[i.date] = Number((incomeData.value[i.date] + i.amount).toFixed(2)) : incomeData.value[i.date] = i.amount
})
let incomeMonths = {
"01": 0,
"02": 0,
"03": 0,
"04": 0,
"05": 0,
"06": 0,
"07": 0,
"08": 0,
"09": 0,
"10": 0,
"11": 0,
"12": 0,
}
Object.keys(incomeMonths).forEach(month => {
let dates = Object.keys(incomeData.value).filter(i => i.split("-")[1] === month && i.split("-")[2] === dayjs().format("YY"))
dates.forEach(date => {
if(incomeMonths[month]){
incomeMonths[month] = Number((incomeMonths[month] + incomeData.value[date]).toFixed(2))
} else {
incomeMonths[month] = incomeData.value[date]
}
})
})
incomeData.value = incomeMonths
}
const days = computed(() => {
let days = []
days = Object.keys(incomeData.value)
let expenseDays = Object.keys(expenseData.value)
expenseDays.forEach(expenseDay => {
if(!days.find(i => i === expenseDay)){
days.push(expenseDay)
}
})
days = days.sort(function(a, b) {
var keyA = dayjs(a, "DD-MM-YY"),
keyB = dayjs(b, "DD-MM-YY");
// Compare the 2 dates
if (keyA.isBefore(keyB,'day')) {
return -1;
} else if(keyB.isBefore(keyA, 'day')) {
return 1
} else {
return 0;
}
});
return days
// Backward compatibility for any existing consumers.
tempStore.modifySettings("dashboardIncomeExpenseMode", amountMode.value)
})
/*const chartData = computed(() => {
return {
labels: days.value,
datasets: [
{
label: 'Einnahmen',
data: [2, 1, 16, 3, 2],
backgroundColor: 'rgba(20, 255, 0, 0.3)',
borderColor: 'red',
borderWidth: 2,
}
]
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.date)
}
const yearsInData = computed(() => {
const years = new Set([dayjs().year()])
incomeDocuments.value.forEach((item) => {
const parsed = dayjs(item.documentDate)
if (parsed.isValid()) years.add(parsed.year())
})
expenseInvoices.value.forEach((item) => {
const parsed = dayjs(item.date)
if (parsed.isValid()) years.add(parsed.year())
})
return Array.from(years).sort((a, b) => b - a)
})
const yearOptions = computed(() => yearsInData.value.map((year) => ({ label: String(year), value: year })))
watch(yearsInData, (years) => {
if (!years.includes(selectedYear.value) && years.length > 0) {
selectedYear.value = years[0]
}
})*/
}, { immediate: true })
const computeDocumentAmount = (doc) => {
let amount = 0
;(doc.rows || []).forEach((row) => {
if (["pagebreak", "title", "text"].includes(row.mode)) return
const net = Number(row.price || 0) * Number(row.quantity || 0) * (1 - Number(row.discountPercent || 0) / 100)
const taxPercent = Number(row.taxPercent)
const gross = net * (1 + (Number.isFinite(taxPercent) ? taxPercent : 0) / 100)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const computeIncomingInvoiceAmount = (invoice) => {
let amount = 0
;(invoice.accounts || []).forEach((account) => {
const net = Number(account.amountNet || 0)
const tax = Number(account.amountTax || 0)
const grossValue = Number(account.amountGross)
const gross = Number.isFinite(grossValue) ? grossValue : (net + tax)
amount += amountMode.value === "gross" ? gross : net
})
return Number(amount.toFixed(2))
}
const buckets = computed(() => {
const income = {}
const expense = {}
if (granularity.value === "year") {
for (let month = 1; month <= 12; month += 1) {
const key = String(month).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
} else {
const daysInMonth = dayjs(`${selectedYear.value}-${String(selectedMonth.value).padStart(2, "0")}-01`).daysInMonth()
for (let day = 1; day <= daysInMonth; day += 1) {
const key = String(day).padStart(2, "0")
income[key] = 0
expense[key] = 0
}
}
incomeDocuments.value.forEach((doc) => {
const docDate = dayjs(doc.documentDate)
if (!docDate.isValid() || docDate.year() !== selectedYear.value) return
if (granularity.value === "month" && docDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(docDate.month() + 1).padStart(2, "0")
: String(docDate.date()).padStart(2, "0")
income[key] = Number((income[key] + computeDocumentAmount(doc)).toFixed(2))
})
expenseInvoices.value.forEach((invoice) => {
const invoiceDate = dayjs(invoice.date)
if (!invoiceDate.isValid() || invoiceDate.year() !== selectedYear.value) return
if (granularity.value === "month" && invoiceDate.month() + 1 !== selectedMonth.value) return
const key = granularity.value === "year"
? String(invoiceDate.month() + 1).padStart(2, "0")
: String(invoiceDate.date()).padStart(2, "0")
expense[key] = Number((expense[key] + computeIncomingInvoiceAmount(invoice)).toFixed(2))
})
return { income, expense }
})
const chartLabels = computed(() => {
if (granularity.value === "year") {
return ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]
}
return Object.keys(buckets.value.income).map((day) => `${day}.`)
})
import { Line } from 'vue-chartjs'
const chartData = computed(() => {
const keys = Object.keys(buckets.value.income).sort()
return {
labels: ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],
labels: chartLabels.value,
datasets: [
{
label: 'Ausgaben',
backgroundColor: '#f87979',
borderColor: '#f87979',
data: Object.keys(expenseData.value).sort().map(i => expenseData.value[i]),
label: "Ausgaben",
backgroundColor: "#f87979",
borderColor: "#f87979",
data: keys.map((key) => buckets.value.expense[key]),
tension: 0.3,
},{
label: 'Einnahmen',
backgroundColor: '#69c350',
borderColor: '#69c350',
data: Object.keys(incomeData.value).sort().map(i => incomeData.value[i]),
},
{
label: "Einnahmen",
backgroundColor: "#69c350",
borderColor: "#69c350",
data: keys.map((key) => buckets.value.income[key]),
tension: 0.3
},
],
}
})
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
})
setup()
loadData()
</script>
<template>
<Line
:data="chartData"
:options="chartOptions"
/>
<div class="h-full flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<USelectMenu
v-model="granularity"
:options="granularityOptions"
value-attribute="value"
option-attribute="label"
class="w-28"
/>
<USelectMenu
v-model="selectedYear"
:options="yearOptions"
value-attribute="value"
option-attribute="label"
class="w-24"
/>
<USelectMenu
v-if="granularity === 'month'"
v-model="selectedMonth"
:options="monthOptions"
value-attribute="value"
option-attribute="label"
class="w-36"
/>
</div>
<UButtonGroup size="xs">
<UButton
:variant="amountMode === 'net' ? 'solid' : 'outline'"
@click="amountMode = 'net'"
>
Netto
</UButton>
<UButton
:variant="amountMode === 'gross' ? 'solid' : 'outline'"
@click="amountMode = 'gross'"
>
Brutto
</UButton>
</UButtonGroup>
</div>
<div class="flex-1 min-h-[280px]">
<Line
:data="chartData"
:options="chartOptions"
/>
</div>
</div>
</template>
<style scoped>

View File

@@ -99,6 +99,10 @@ const setupData = async () => {
}
const loaded = ref(false)
const normalizeEntityId = (value) => {
if (value === null || typeof value === "undefined") return null
return typeof value === "object" ? (value.id ?? null) : value
}
const setupPage = async () => {
await setupData()
@@ -138,14 +142,15 @@ const setupPage = async () => {
if (route.query.loadMode === "deliveryNotes") {
let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id))
if (linkedDocuments.length === 0) return
//TODO: Implement Checking for Same Customer, Contact and Project
itemInfo.value.customer = linkedDocuments[0].customer ? linkedDocuments[0].customer.id : null
itemInfo.value.project = linkedDocuments[0].project ? linkedDocuments[0].project.id : null
itemInfo.value.contact = linkedDocuments[0].contact ? linkedDocuments[0].contact.id : null
itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData()
await setCustomerData(null, true)
let firstDate = null
let lastDate = null
@@ -207,21 +212,23 @@ const setupPage = async () => {
}
else if (route.query.loadMode === "finalInvoice") {
let linkedDocuments = (await useEntities("createddocuments").select()).filter(i => JSON.parse(route.query.linkedDocuments).includes(i.id))
if (linkedDocuments.length === 0) return
//TODO: Implement Checking for Same Customer, Contact and Project
console.log(linkedDocuments)
itemInfo.value.customer = linkedDocuments[0].customer
itemInfo.value.project = linkedDocuments[0].project
itemInfo.value.contact = linkedDocuments[0].contact
itemInfo.value.customer = normalizeEntityId(linkedDocuments[0].customer)
itemInfo.value.project = normalizeEntityId(linkedDocuments[0].project)
itemInfo.value.contact = normalizeEntityId(linkedDocuments[0].contact)
setCustomerData()
await setCustomerData(null, true)
for await (const doc of linkedDocuments.filter(i => i.type === "confirmationOrders")) {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: linkedDocument.title,
})
@@ -233,6 +240,7 @@ const setupPage = async () => {
let linkedDocument = await useEntities("createddocuments").selectSingle(doc.id)
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: linkedDocument.title,
})
@@ -241,6 +249,7 @@ const setupPage = async () => {
}
itemInfo.value.rows.push({
id: uuidv4(),
mode: "title",
text: "Abschlagsrechnungen",
})
@@ -2064,6 +2073,7 @@ const setRowData = async (row, service = {sellingPriceComposed: {}}, product = {
>
<UInput
type="number"
step="0.01"
v-model="itemInfo.customSurchargePercentage"
@change="updateCustomSurcharge"
>

View File

@@ -158,13 +158,24 @@
<UDivider label="Vorlagen auswählen" />
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<div class="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<UInput
v-model="modalSearch"
icon="i-heroicons-magnifying-glass"
placeholder="Kunde oder Vertrag suchen..."
class="w-full sm:w-64"
size="sm"
/>
<USelectMenu
v-model="selectedExecutionIntervall"
:options="executionIntervallOptions"
option-attribute="label"
value-attribute="value"
size="sm"
class="w-full sm:w-52"
/>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 hidden sm:inline">
@@ -201,11 +212,14 @@
{{displayCurrency(calculateDocSum(row))}}
</template>
<template #serialConfig.intervall-data="{row}">
{{ row.serialConfig?.intervall }}
{{ getIntervallLabel(row.serialConfig?.intervall) }}
</template>
<template #contract-data="{row}">
{{row.contract?.contractNumber}} - {{row.contract?.name}}
</template>
<template #plant-data="{row}">
{{ row.plant?.name || "-" }}
</template>
</UTable>
</div>
@@ -287,6 +301,7 @@ const executionDate = ref(dayjs().format('YYYY-MM-DD'))
const selectedExecutionRows = ref([])
const isExecuting = ref(false)
const modalSearch = ref("") // NEU: Suchstring für das Modal
const selectedExecutionIntervall = ref("all")
// --- SerialExecutions State ---
const showExecutionsSlideover = ref(false)
@@ -295,7 +310,7 @@ const executionsLoading = ref(false)
const finishingId = ref(null)
const setupPage = async () => {
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber)","documentDate",undefined,true)
items.value = await useEntities("createddocuments").select("*, customer(id,name), contract(id,name, contractNumber), plant(id,name)","documentDate",undefined,true)
await fetchExecutions()
}
@@ -390,30 +405,78 @@ const filteredRows = computed(() => {
return useSearch(searchString.value, temp.slice().reverse())
})
// Basis Liste für das Modal (nur Aktive)
// Basis Liste für das Modal (nur aktive und nicht archivierte Vorlagen)
const activeTemplates = computed(() => {
return items.value
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active)
.filter(i => i.type === "serialInvoices" && !!i.serialConfig?.active && !i.archived)
.map(i => ({...i}))
})
const intervallLabelMap = {
"wöchentlich": "Wöchentlich",
"2 - wöchentlich": "Alle 2 Wochen",
"monatlich": "Monatlich",
"vierteljährlich": "Quartalsweise",
"halbjährlich": "Halbjährlich",
"jährlich": "Jährlich"
}
const getIntervallLabel = (intervall) => {
if (!intervall) return "-"
return intervallLabelMap[intervall] || intervall
}
const executionIntervallOptions = computed(() => {
const availableIntervals = [...new Set(
activeTemplates.value
.map(row => row.serialConfig?.intervall)
.filter(Boolean)
)]
const sorted = availableIntervals.sort((a, b) =>
getIntervallLabel(a).localeCompare(getIntervallLabel(b), 'de')
)
return [
{label: 'Alle Intervalle', value: 'all'},
...sorted.map(intervall => ({
label: getIntervallLabel(intervall),
value: intervall
}))
]
})
// NEU: Gefilterte Liste für das Modal basierend auf der Suche
const filteredExecutionList = computed(() => {
if (!modalSearch.value) return activeTemplates.value
let filtered = [...activeTemplates.value]
if (selectedExecutionIntervall.value !== 'all') {
filtered = filtered.filter(
row => row.serialConfig?.intervall === selectedExecutionIntervall.value
)
}
if (!modalSearch.value) return filtered
const term = modalSearch.value.toLowerCase()
return activeTemplates.value.filter(row => {
return filtered.filter(row => {
const customerName = row.customer?.name?.toLowerCase() || ""
const contractNum = row.contract?.contractNumber?.toLowerCase() || ""
const contractName = row.contract?.name?.toLowerCase() || ""
const plantName = row.plant?.name?.toLowerCase() || ""
return customerName.includes(term) ||
contractNum.includes(term) ||
contractName.includes(term)
contractName.includes(term) ||
plantName.includes(term)
})
})
watch(selectedExecutionIntervall, () => {
selectedExecutionRows.value = [...filteredExecutionList.value]
})
// NEU: Alle auswählen (nur die aktuell sichtbaren/gefilterten)
const selectAllTemplates = () => {
// WICHTIG: Überschreibt nicht bestehende Auswahl, sondern fügt hinzu oder ersetzt.
@@ -469,6 +532,7 @@ const templateColumns = [
const executionColumns = [
{key: 'partner', label: "Kunde"},
{key: 'plant', label: "Objekt"},
{key: 'contract', label: "Vertrag"},
{key: 'serialConfig.intervall', label: "Intervall"},
{key: "amount", label: "Betrag"},
@@ -509,8 +573,9 @@ const calculateDocSum = (row) => {
const openExecutionModal = () => {
executionDate.value = dayjs().format('YYYY-MM-DD')
selectedExecutionRows.value = []
modalSearch.value = "" // Reset Search
selectedExecutionIntervall.value = "all"
selectedExecutionRows.value = []
showExecutionModal.value = true
}

View File

@@ -18,7 +18,10 @@ defineShortcuts({
'Enter': {
usingInput: true,
handler: () => {
router.push(`/incomingInvoices/show/${filteredRows.value[selectedItem.value].id}`)
const invoice = filteredRows.value[selectedItem.value]
if (invoice) {
selectIncomingInvoice(invoice)
}
}
},
'arrowdown': () => {
@@ -146,13 +149,11 @@ const isPaid = (item) => {
}
const selectIncomingInvoice = (invoice) => {
if(invoice.state === "Vorbereitet" ) {
router.push(`/incomingInvoices/edit/${invoice.id}`)
} else {
if (invoice.state === "Gebucht") {
router.push(`/incomingInvoices/show/${invoice.id}`)
} else {
router.push(`/incomingInvoices/edit/${invoice.id}`)
}
}

View File

@@ -14,7 +14,7 @@
<UDashboardPanelContent>
<div class="mb-5">
<UDashboardCard
title="Einnahmen und Ausgaben(netto)"
title="Einnahmen und Ausgaben"
class="mt-3"
>
<display-income-and-expenditure/>

View File

@@ -1,6 +1,108 @@
<script setup>
const auth = useAuthStore()
const defaultFeatures = {
objects: true,
calendar: true,
contacts: true,
projects: true,
vehicles: true,
contracts: true,
inventory: true,
accounting: true,
timeTracking: true,
planningBoard: true,
workingTimeTracking: true,
dashboard: true,
historyitems: true,
tasks: true,
wiki: true,
files: true,
createdletters: true,
documentboxes: true,
helpdesk: true,
email: true,
members: true,
customers: true,
vendors: true,
contactsList: true,
staffTime: true,
createDocument: true,
serialInvoice: true,
incomingInvoices: true,
costcentres: true,
accounts: true,
ownaccounts: true,
banking: true,
spaces: true,
customerspaces: true,
customerinventoryitems: true,
inventoryitems: true,
inventoryitemgroups: true,
products: true,
productcategories: true,
services: true,
servicecategories: true,
memberrelations: true,
staffProfiles: true,
hourrates: true,
projecttypes: true,
contracttypes: true,
plants: true,
settingsNumberRanges: true,
settingsEmailAccounts: true,
settingsBanking: true,
settingsTexttemplates: true,
settingsTenant: true,
export: true,
}
const featureOptions = [
{ key: "dashboard", label: "Dashboard" },
{ key: "historyitems", label: "Logbuch" },
{ key: "tasks", label: "Aufgaben" },
{ key: "wiki", label: "Wiki" },
{ key: "files", label: "Dateien" },
{ key: "createdletters", label: "Anschreiben" },
{ key: "documentboxes", label: "Boxen" },
{ key: "helpdesk", label: "Helpdesk" },
{ key: "email", label: "E-Mail" },
{ key: "members", label: "Mitglieder" },
{ key: "customers", label: "Kunden" },
{ key: "vendors", label: "Lieferanten" },
{ key: "contactsList", label: "Ansprechpartner" },
{ key: "staffTime", label: "Mitarbeiter: Zeiten" },
{ key: "createDocument", label: "Buchhaltung: Ausgangsbelege" },
{ key: "serialInvoice", label: "Buchhaltung: Serienvorlagen" },
{ key: "incomingInvoices", label: "Buchhaltung: Eingangsbelege" },
{ key: "costcentres", label: "Buchhaltung: Kostenstellen" },
{ key: "accounts", label: "Buchhaltung: Buchungskonten" },
{ key: "ownaccounts", label: "Buchhaltung: Zusätzliche Buchungskonten" },
{ key: "banking", label: "Buchhaltung: Bank" },
{ key: "spaces", label: "Lagerplätze" },
{ key: "customerspaces", label: "Kundenlagerplätze" },
{ key: "customerinventoryitems", label: "Kundeninventar" },
{ key: "inventoryitems", label: "Inventar" },
{ key: "inventoryitemgroups", label: "Inventargruppen" },
{ key: "products", label: "Stammdaten: Artikel" },
{ key: "productcategories", label: "Stammdaten: Artikelkategorien" },
{ key: "services", label: "Stammdaten: Leistungen" },
{ key: "servicecategories", label: "Stammdaten: Leistungskategorien" },
{ key: "memberrelations", label: "Stammdaten: Mitgliedsverhältnisse" },
{ key: "staffProfiles", label: "Stammdaten: Mitarbeiter" },
{ key: "hourrates", label: "Stammdaten: Stundensätze" },
{ key: "projecttypes", label: "Stammdaten: Projekttypen" },
{ key: "contracttypes", label: "Stammdaten: Vertragstypen" },
{ key: "vehicles", label: "Stammdaten: Fahrzeuge" },
{ key: "projects", label: "Projekte" },
{ key: "contracts", label: "Verträge" },
{ key: "plants", label: "Objekte" },
{ key: "settingsNumberRanges", label: "Einstellungen: Nummernkreise" },
{ key: "settingsEmailAccounts", label: "Einstellungen: E-Mail Konten" },
{ key: "settingsBanking", label: "Einstellungen: Bankkonten" },
{ key: "settingsTexttemplates", label: "Einstellungen: Textvorlagen" },
{ key: "settingsTenant", label: "Einstellungen: Firmeneinstellungen" },
{ key: "export", label: "Einstellungen: Export" },
]
const itemInfo = ref({
features: {},
@@ -13,8 +115,13 @@ const setupPage = async () => {
console.log(itemInfo.value)
}
const features = ref(auth.activeTenantData.features)
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(auth.activeTenantData.businessInfo)
const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const accountChartOptions = [
{ label: "SKR 03", value: "skr03" },
{ label: "Verein", value: "verein" }
]
const updateTenant = async (newData) => {
@@ -24,6 +131,15 @@ const updateTenant = async (newData) => {
data: newData,
}
})
if (res) {
itemInfo.value = res
auth.activeTenantData = res
features.value = { ...defaultFeatures, ...(res?.features || {}) }
}
}
const saveFeatures = async () => {
await updateTenant({features: features.value})
}
setupPage()
@@ -40,6 +156,8 @@ setupPage()
label: 'Dokubox'
},{
label: 'Rechnung & Kontakt'
},{
label: 'Funktionen'
}
]"
>
@@ -63,8 +181,8 @@ setupPage()
</div>
<div v-if="item.label === 'Rechnung & Kontakt'">
<UCard class="mt-5">
<UForm class="w-1/2">
<UCard class="mt-5">
<UForm class="w-1/2">
<UFormGroup
label="Firmenname:"
>
@@ -90,6 +208,23 @@ setupPage()
>
Speichern
</UButton>
<UFormGroup
label="Kontenrahmen:"
class="mt-6"
>
<USelectMenu
v-model="accountChart"
:options="accountChartOptions"
option-attribute="label"
value-attribute="value"
/>
</UFormGroup>
<UButton
class="mt-3"
@click="updateTenant({accountChart: accountChart})"
>
Kontenrahmen speichern
</UButton>
</UForm>
</UCard>
@@ -104,59 +239,11 @@ setupPage()
class="mb-5"
/>
<UCheckbox
label="Kalendar"
v-model="features.calendar"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Kontakte"
v-model="features.contacts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Plantafel"
v-model="features.planningBoard"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Zeiterfassung"
v-model="features.timeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Anwesenheiten"
v-model="features.workingTimeTracking"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Lager"
v-model="features.inventory"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Fahrzeuge"
v-model="features.vehicles"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Buchhaltung"
v-model="features.accounting"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Projekte"
v-model="features.projects"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Verträge"
v-model="features.contracts"
@change="updateTenant({features: features})"
/>
<UCheckbox
label="Objekte"
v-model="features.objects"
@change="updateTenant({features: features})"
v-for="option in featureOptions"
:key="option.key"
:label="option.label"
v-model="features[option.key]"
@change="saveFeatures"
/>
</UCard>
</div>

View File

@@ -142,13 +142,6 @@ function getAssigneeLabel(task) {
return assigneeOptions.value.find((option) => option.value === assigneeId)?.label || "-"
}
function getStatusBadgeColor(status) {
const normalized = normalizeStatus(status)
if (normalized === "Offen") return "gray"
if (normalized === "In Bearbeitung") return "amber"
return "green"
}
async function loadTasks() {
loading.value = true
try {
@@ -368,7 +361,6 @@ onMounted(async () => {
/>
<UButton
icon="i-heroicons-arrow-path"
color="gray"
variant="soft"
:loading="loading"
@click="loadTasks"
@@ -391,15 +383,15 @@ onMounted(async () => {
v-model="showOnlyMine"
label="Nur meine Aufgaben"
/>
<span v-else class="text-sm text-gray-500">Ansicht: Nur eigene Aufgaben</span>
<span v-else class="text-sm">Ansicht: Nur eigene Aufgaben</span>
</template>
<template #right>
<div class="flex items-center gap-1 rounded-lg border border-gray-200 p-1 dark:border-gray-700">
<div class="view-toggle">
<UButton
size="xs"
variant="ghost"
icon="i-heroicons-view-columns"
:color="viewMode === 'kanban' ? 'primary' : 'gray'"
:variant="viewMode === 'kanban' ? 'solid' : 'ghost'"
@click="viewMode = 'kanban'"
>
Kanban
@@ -408,7 +400,7 @@ onMounted(async () => {
size="xs"
variant="ghost"
icon="i-heroicons-list-bullet"
:color="viewMode === 'list' ? 'primary' : 'gray'"
:variant="viewMode === 'list' ? 'solid' : 'ghost'"
@click="viewMode = 'list'"
>
Liste
@@ -429,7 +421,7 @@ onMounted(async () => {
>
<header class="kanban-column-header">
<h3>{{ status }}</h3>
<UBadge color="gray" variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
<UBadge variant="subtle">{{ groupedTasks[status]?.length || 0 }}</UBadge>
</header>
<div :class="['kanban-dropzone', droppingOn === status ? 'kanban-dropzone-active' : '']">
@@ -445,13 +437,13 @@ onMounted(async () => {
<p v-if="task.description" class="kanban-card-description">{{ task.description }}</p>
<div class="kanban-card-meta">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" color="primary" variant="soft">
<UBadge v-if="getEntityLabel(projectOptions, task.project?.id || task.project)" variant="soft">
{{ getEntityLabel(projectOptions, task.project?.id || task.project) }}
</UBadge>
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" color="gray" variant="soft">
<UBadge v-if="getEntityLabel(customerOptions, task.customer?.id || task.customer)" variant="soft">
{{ getEntityLabel(customerOptions, task.customer?.id || task.customer) }}
</UBadge>
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" color="gray" variant="soft">
<UBadge v-if="getEntityLabel(plantOptions, task.plant?.id || task.plant)" variant="soft">
{{ getEntityLabel(plantOptions, task.plant?.id || task.plant) }}
</UBadge>
</div>
@@ -464,17 +456,15 @@ onMounted(async () => {
</section>
</div>
<UTable
v-else
v-else-if="filteredTasks.length"
:rows="filteredTasks"
:columns="listColumns"
:empty-state="{ icon: 'i-heroicons-circle-stack-20-solid', label: 'Keine Aufgaben anzuzeigen' }"
@select="(task) => openTaskViaRoute(task)"
>
<template #actions-data="{ row }">
<UButton
v-if="normalizeStatus(row.categorie) !== 'Abgeschlossen' && canCreate"
size="xs"
color="green"
variant="soft"
icon="i-heroicons-check"
:loading="quickCompleteLoadingId === row.id"
@@ -484,9 +474,7 @@ onMounted(async () => {
</UButton>
</template>
<template #categorie-data="{ row }">
<UBadge :color="getStatusBadgeColor(row.categorie)" variant="soft">
{{ normalizeStatus(row.categorie) }}
</UBadge>
<UBadge variant="soft">{{ normalizeStatus(row.categorie) }}</UBadge>
</template>
<template #assignee-data="{ row }">
{{ getAssigneeLabel(row) }}
@@ -501,6 +489,12 @@ onMounted(async () => {
{{ getEntityLabel(plantOptions, row.plant?.id || row.plant) || "-" }}
</template>
</UTable>
<UAlert
v-else
icon="i-heroicons-circle-stack-20-solid"
title="Keine Aufgaben anzuzeigen"
variant="subtle"
/>
</UDashboardPanelContent>
<UModal v-model="isModalOpen" :prevent-close="saving || deleting">
@@ -508,7 +502,7 @@ onMounted(async () => {
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ modalTitle }}</h3>
<UBadge color="gray" variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
<UBadge variant="subtle">{{ taskForm.id ? `#${taskForm.id}` : "Neu" }}</UBadge>
</div>
</template>
@@ -595,7 +589,6 @@ onMounted(async () => {
<div class="flex gap-2">
<UButton
v-if="taskForm.id && canCreate"
color="red"
variant="soft"
:loading="deleting"
@click="archiveTask"
@@ -605,10 +598,9 @@ onMounted(async () => {
</div>
<div class="flex gap-2">
<UButton color="gray" variant="ghost" @click="closeModal">Schließen</UButton>
<UButton variant="ghost" @click="closeModal">Schließen</UButton>
<UButton
v-if="modalMode === 'show' && canCreate"
color="gray"
variant="soft"
@click="modalMode = 'edit'"
>
@@ -632,7 +624,8 @@ onMounted(async () => {
.kanban-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
gap: 1.25rem;
align-items: start;
}
@media (min-width: 1024px) {
@@ -642,38 +635,56 @@ onMounted(async () => {
}
.kanban-column {
border: 1px solid rgb(229 231 235);
border: 1px solid var(--ui-border);
border-radius: 0.75rem;
background: rgb(249 250 251);
background: var(--ui-bg);
min-height: 500px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
box-shadow: 0 1px 2px color-mix(in oklab, var(--ui-text) 10%, transparent);
}
@media (min-width: 1024px) {
.kanban-column:not(:first-child)::before {
content: "";
position: absolute;
left: -0.7rem;
top: 0.75rem;
bottom: 0.75rem;
width: 1px;
background: var(--ui-border);
opacity: 0.9;
pointer-events: none;
}
}
.kanban-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0.9rem;
border-bottom: 1px solid rgb(229 231 235);
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-bg-muted);
}
.kanban-dropzone {
padding: 0.75rem;
padding: 0.9rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.85rem;
transition: background-color 0.2s ease;
}
.kanban-dropzone-active {
background: rgb(239 246 255);
background: var(--ui-bg-muted);
}
.kanban-card {
border: 1px solid rgb(229 231 235);
background: white;
border: 1px solid var(--ui-border);
background: var(--ui-bg-elevated);
border-radius: 0.6rem;
padding: 0.7rem;
cursor: pointer;
@@ -686,7 +697,7 @@ onMounted(async () => {
.kanban-card-description {
margin-top: 0.4rem;
color: rgb(107 114 128);
color: var(--ui-text-dimmed);
font-size: 0.875rem;
line-height: 1.15rem;
}
@@ -699,8 +710,8 @@ onMounted(async () => {
}
.kanban-empty {
border: 1px dashed rgb(209 213 219);
color: rgb(156 163 175);
border: 1px dashed var(--ui-border);
color: var(--ui-text-dimmed);
border-radius: 0.6rem;
padding: 0.7rem;
font-size: 0.875rem;
@@ -711,36 +722,15 @@ onMounted(async () => {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--ui-text);
}
:global(.dark) .kanban-column {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
}
:global(.dark) .kanban-column-header {
border-bottom-color: rgb(55 65 81);
}
:global(.dark) .kanban-dropzone-active {
background: rgb(30 41 59);
}
:global(.dark) .kanban-card {
border-color: rgb(75 85 99);
background: rgb(31 41 55);
}
:global(.dark) .kanban-card-description {
color: rgb(209 213 219);
}
:global(.dark) .kanban-empty {
border-color: rgb(75 85 99);
color: rgb(156 163 175);
}
:global(.dark) .form-label {
color: rgb(229 231 235);
.view-toggle {
display: flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--ui-border);
border-radius: 0.5rem;
padding: 0.25rem;
}
</style>

View File

@@ -535,7 +535,7 @@ export const useDataStore = defineStore('data', () => {
disabledInTable: true
},
{
key: "infoData.memberrelation",
key: "memberrelation",
label: "Mitgliedsverhältnis",
component: memberrelation,
inputType: "select",
@@ -1237,9 +1237,9 @@ export const useDataStore = defineStore('data', () => {
selectOptionAttribute: "name",
selectSearchAttributes: ['name'],
},{
key: "description",
key: "description.text",
label: "Beschreibung",
inputType:"editor",
inputType:"textarea",
component: description
},
],

1
mobile/.env Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_API_BASE=http://192.168.1.157:3100

1
mobile/.env.example Normal file
View File

@@ -0,0 +1 @@
EXPO_PUBLIC_API_BASE=http://localhost:3100

43
mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

1
mobile/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
mobile/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

50
mobile/README.md Normal file
View File

@@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

64
mobile/app.json Normal file
View File

@@ -0,0 +1,64 @@
{
"expo": {
"name": "FEDEO",
"slug": "fedeo-mobile",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "fedeo",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "de.fedeo.mobile",
"buildNumber": "1",
"infoPlist": {
"NSCameraUsageDescription": "Die Kamera wird benötigt, um Fotos zu Projekten und Objekten als Dokumente hochzuladen.",
"NSPhotoLibraryUsageDescription": "Der Zugriff auf Fotos wird benötigt, um Bilder als Dokumente hochzuladen.",
"NSPhotoLibraryAddUsageDescription": "Die App benötigt Zugriff, um Fotos für Uploads zu speichern und zu verwenden.",
"NSBluetoothAlwaysUsageDescription": "Bluetooth wird benötigt, um den Nimbot M2 Etikettendrucker zu verbinden.",
"NSBluetoothPeripheralUsageDescription": "Bluetooth wird benötigt, um mit dem Nimbot M2 zu kommunizieren."
}
},
"android": {
"package": "de.fedeo.mobile",
"permissions": [
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.ACCESS_FINE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"react-native-ble-plx"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,69 @@
import { Redirect, Tabs } from 'expo-router';
import { HapticTab } from '@/components/haptic-tab';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useAuth } from '@/src/providers/auth-provider';
export default function TabLayout() {
const colorScheme = useColorScheme();
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
if (isBootstrapping) {
return null;
}
if (!token) {
return <Redirect href="/login" />;
}
if (requiresTenantSelection) {
return <Redirect href="/tenant-select" />;
}
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: true,
tabBarButton: HapticTab,
}}>
<Tabs.Screen
name="index"
options={{
title: 'Dashboard',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="tasks"
options={{
title: 'Aufgaben',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="checklist" color={color} />,
}}
/>
<Tabs.Screen
name="projects"
options={{
title: 'Projekte',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="folder.fill" color={color} />,
}}
/>
<Tabs.Screen
name="time"
options={{
title: 'Zeit',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="clock.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Mehr',
tabBarIcon: ({ color }) => <IconSymbol size={24} name="ellipsis.circle.fill" color={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,139 @@
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
const ITEMS = [
{
key: 'account',
title: 'Konto',
subtitle: 'Session, Tenant-Wechsel, Logout',
href: '/more/account',
},
{
key: 'settings',
title: 'Einstellungen',
subtitle: 'Server-Instanz verwalten',
href: '/more/settings',
},
{
key: 'wiki',
title: 'Wiki',
subtitle: 'Wissen und Dokumentation',
href: '/more/wiki',
},
{
key: 'customers',
title: 'Kunden',
subtitle: '',
href: '/more/customers',
},
{
key: 'plants',
title: 'Objekte',
subtitle: '',
href: '/more/plants',
},
{
key: 'inventory',
title: 'Kundeninventar',
subtitle: 'Inventar und Scanner',
href: '/more/inventory',
},
{
key: 'nimbot',
title: 'Nimbot M2',
subtitle: 'Drucker verbinden',
href: '/more/nimbot',
},
];
export default function MoreScreen() {
return (
<View style={styles.screen}>
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Funktionen</Text>
<Text style={styles.sectionSubtitle}>Weitere Bereiche und Einstellungen.</Text>
</View>
{ITEMS.map((item, index) => (
<Pressable key={item.key} style={styles.row} onPress={() => router.push(item.href as any)}>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>{item.title}</Text>
<Text style={styles.rowSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.rowArrow}></Text>
{index < ITEMS.length - 1 ? <View style={styles.rowDivider} /> : null}
</Pressable>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#ffffff',
},
list: {
flex: 1,
backgroundColor: '#ffffff',
},
listContent: {
paddingBottom: 24,
},
section: {
paddingHorizontal: 16,
paddingTop: 14,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
},
sectionTitle: {
color: '#111827',
fontSize: 17,
fontWeight: '700',
},
sectionSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 3,
},
row: {
paddingHorizontal: 16,
paddingVertical: 14,
backgroundColor: '#ffffff',
position: 'relative',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
},
rowMain: {
flex: 1,
minWidth: 0,
},
rowTitle: {
color: '#111827',
fontSize: 15,
fontWeight: '600',
},
rowSubtitle: {
color: '#6b7280',
fontSize: 13,
marginTop: 2,
},
rowArrow: {
color: '#9ca3af',
fontSize: 24,
lineHeight: 24,
},
rowDivider: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
height: 1,
backgroundColor: '#e5e7eb',
},
});

269
mobile/app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,269 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router } from 'expo-router';
import { fetchStaffTimeSpans, fetchTasks, Task } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
type DashboardData = {
tasks: Task[];
openTasks: number;
inProgressTasks: number;
activeTimeStart: string | null;
pendingSubmissions: number;
todayMinutes: number;
};
function normalizeTaskStatus(value: unknown): 'Offen' | 'In Bearbeitung' | 'Abgeschlossen' {
if (value === 'In Bearbeitung') return 'In Bearbeitung';
if (value === 'Abgeschlossen') return 'Abgeschlossen';
return 'Offen';
}
function formatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${String(m).padStart(2, '0')}m`;
}
function formatDateTime(value: string | null): string {
if (!value) return '-';
return new Date(value).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function DashboardScreen() {
const { token, user } = useAuth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<DashboardData>({
tasks: [],
openTasks: 0,
inProgressTasks: 0,
activeTimeStart: null,
pendingSubmissions: 0,
todayMinutes: 0,
});
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const loadDashboard = useCallback(
async (showSpinner = true) => {
if (!token || !currentUserId) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, spans] = await Promise.all([
fetchTasks(token),
fetchStaffTimeSpans(token, currentUserId),
]);
const tasks = taskRows || [];
const openTasks = tasks.filter((task) => normalizeTaskStatus(task.categorie) === 'Offen').length;
const inProgressTasks = tasks.filter(
(task) => normalizeTaskStatus(task.categorie) === 'In Bearbeitung'
).length;
const activeTime = spans.find((span) => !span.stopped_at) || null;
const pendingSubmissions = spans.filter(
(span) => (span.state === 'draft' || span.state === 'factual') && !!span.stopped_at
).length;
const today = new Date();
const todayIso = today.toISOString().slice(0, 10);
const todayMinutes = spans
.filter((span) => span.started_at?.slice(0, 10) === todayIso)
.reduce((sum, span) => sum + (span.duration_minutes || 0), 0);
setData({
tasks,
openTasks,
inProgressTasks,
activeTimeStart: activeTime?.started_at || null,
pendingSubmissions,
todayMinutes,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Dashboard konnte nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[currentUserId, token]
);
useEffect(() => {
if (!token || !currentUserId) return;
void loadDashboard(true);
}, [currentUserId, loadDashboard, token]);
async function onRefresh() {
setRefreshing(true);
await loadDashboard(false);
}
return (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Dashboard wird geladen...</Text>
</View>
) : null}
{!loading ? (
<>
<View style={styles.row}>
<View style={[styles.metricCard, styles.metricCardPrimary]}>
<Text style={styles.metricLabelPrimary}>Aktive Zeit</Text>
<Text style={styles.metricValuePrimary}>
{data.activeTimeStart ? `Seit ${formatDateTime(data.activeTimeStart)}` : 'Nicht aktiv'}
</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Offene Aufgaben</Text>
<Text style={styles.metricValue}>{data.openTasks}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>In Bearbeitung</Text>
<Text style={styles.metricValue}>{data.inProgressTasks}</Text>
</View>
</View>
<View style={styles.row}>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Heute erfasst</Text>
<Text style={styles.metricValue}>{formatMinutes(data.todayMinutes)}</Text>
</View>
<View style={styles.metricCard}>
<Text style={styles.metricLabel}>Zum Einreichen</Text>
<Text style={styles.metricValue}>{data.pendingSubmissions}</Text>
</View>
</View>
<View style={styles.quickActionsCard}>
<Text style={styles.quickActionsTitle}>Schnellzugriff</Text>
<View style={styles.quickActionsRow}>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/tasks')}>
<Text style={styles.quickActionText}>Aufgaben</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/projects')}>
<Text style={styles.quickActionText}>Projekten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/(tabs)/time')}>
<Text style={styles.quickActionText}>Zeiten</Text>
</Pressable>
<Pressable style={styles.quickActionButton} onPress={() => router.push('/more/inventory?action=scan')}>
<Text style={styles.quickActionText}>Inventar Scan</Text>
</Pressable>
</View>
</View>
</>
) : null}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 12,
backgroundColor: '#f9fafb',
},
row: {
flexDirection: 'row',
gap: 10,
},
metricCard: {
flex: 1,
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 6,
},
metricCardPrimary: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
metricLabel: {
color: '#6b7280',
fontSize: 12,
textTransform: 'uppercase',
},
metricValue: {
color: '#111827',
fontSize: 22,
fontWeight: '700',
},
metricLabelPrimary: {
color: '#3d7a30',
fontSize: 12,
textTransform: 'uppercase',
},
metricValuePrimary: {
color: '#2f5f24',
fontSize: 18,
fontWeight: '700',
},
quickActionsCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
padding: 12,
gap: 10,
},
quickActionsTitle: {
color: '#111827',
fontSize: 15,
fontWeight: '600',
},
quickActionsRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
quickActionButton: {
minWidth: 120,
minHeight: 40,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
quickActionText: {
color: '#ffffff',
fontWeight: '600',
},
error: {
color: '#dc2626',
fontSize: 13,
},
loadingBox: {
padding: 16,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
});

View File

@@ -0,0 +1,533 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { router } from 'expo-router';
import { createProject, Customer, fetchCustomers, fetchPlants, fetchProjects, Plant, Project } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function getProjectLine(project: Project): string {
if (project.projectNumber) return `${project.name} · ${project.projectNumber}`;
return project.name;
}
function getActivePhaseLabel(project: Project): string {
const explicit = String(project.active_phase || '').trim();
if (explicit) return explicit;
const phases = Array.isArray(project.phases) ? project.phases : [];
const active = phases.find((phase: any) => phase?.active);
return String(active?.label || '').trim();
}
function isProjectCompletedByPhase(project: Project): boolean {
const phase = getActivePhaseLabel(project).toLowerCase();
return phase === 'abgeschlossen';
}
export default function ProjectsScreen() {
const { token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [customers, setCustomers] = useState<Customer[]>([]);
const [plants, setPlants] = useState<Plant[]>([]);
const [search, setSearch] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [nameInput, setNameInput] = useState('');
const [projectNumberInput, setProjectNumberInput] = useState('');
const [notesInput, setNotesInput] = useState('');
const [selectedCustomerId, setSelectedCustomerId] = useState<number | null>(null);
const [selectedPlantId, setSelectedPlantId] = useState<number | null>(null);
const [pickerMode, setPickerMode] = useState<'customer' | 'plant' | null>(null);
const [customerSearch, setCustomerSearch] = useState('');
const [plantSearch, setPlantSearch] = useState('');
const filteredProjects = useMemo(() => {
const terms = search
.trim()
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
return projects.filter((project) => {
if (!showArchived && isProjectCompletedByPhase(project)) return false;
if (terms.length === 0) return true;
const haystack = [
project.name,
project.projectNumber,
project.notes,
project.customerRef,
project.active_phase,
getActivePhaseLabel(project),
]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [projects, search, showArchived]);
const selectedCustomerLabel = useMemo(() => {
if (!selectedCustomerId) return 'Kunde auswählen (optional)';
const customer = customers.find((item) => Number(item.id) === selectedCustomerId);
return customer ? `${customer.name}${customer.customerNumber ? ` · ${customer.customerNumber}` : ''}` : `ID ${selectedCustomerId}`;
}, [customers, selectedCustomerId]);
const selectedPlantLabel = useMemo(() => {
if (!selectedPlantId) return 'Objekt auswählen (optional)';
const plant = plants.find((item) => Number(item.id) === selectedPlantId);
return plant ? plant.name : `ID ${selectedPlantId}`;
}, [plants, selectedPlantId]);
const filteredCustomerOptions = useMemo(() => {
const terms = customerSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return customers
.filter((customer) => !customer.archived)
.filter((customer) => {
if (terms.length === 0) return true;
const haystack = [customer.name, customer.customerNumber]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [customerSearch, customers]);
const filteredPlantOptions = useMemo(() => {
const terms = plantSearch.trim().toLowerCase().split(/\s+/).filter(Boolean);
return plants
.filter((plant) => !plant.archived)
.filter((plant) => {
if (terms.length === 0) return true;
const haystack = [plant.name, plant.description]
.map((value) => String(value || '').toLowerCase())
.join(' ');
return terms.every((term) => haystack.includes(term));
});
}, [plantSearch, plants]);
const loadProjects = useCallback(async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [projectRows, customerRows, plantRows] = await Promise.all([
fetchProjects(token, true),
fetchCustomers(token, true),
fetchPlants(token, true),
]);
setProjects(projectRows);
setCustomers(customerRows);
setPlants(plantRows);
} catch (err) {
setError(err instanceof Error ? err.message : 'Projekte konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [token]);
useEffect(() => {
if (!token) return;
void loadProjects(true);
}, [loadProjects, token]);
async function onRefresh() {
setRefreshing(true);
await loadProjects(false);
}
function closeCreateModal() {
setCreateOpen(false);
setCreateError(null);
setNameInput('');
setProjectNumberInput('');
setNotesInput('');
setSelectedCustomerId(null);
setSelectedPlantId(null);
setCustomerSearch('');
setPlantSearch('');
setPickerMode(null);
}
async function onCreateProject() {
if (!token) return;
const name = nameInput.trim();
if (!name) {
setCreateError('Bitte einen Projektnamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createProject(token, {
name,
projectNumber: projectNumberInput.trim() || null,
customer: selectedCustomerId,
plant: selectedPlantId,
notes: notesInput.trim() || null,
});
closeCreateModal();
await loadProjects(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Projekt konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
return (
<View style={styles.screen}>
<View style={styles.searchWrap}>
<TextInput
placeholder="Projekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={search}
onChangeText={setSearch}
/>
<Pressable
style={[styles.toggleButton, showArchived ? styles.toggleButtonActive : null]}
onPress={() => setShowArchived((prev) => !prev)}>
<Text style={[styles.toggleButtonText, showArchived ? styles.toggleButtonTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Projekte werden geladen...</Text>
</View>
) : null}
{!loading && filteredProjects.length === 0 ? <Text style={styles.empty}>Keine Projekte gefunden.</Text> : null}
{!loading &&
filteredProjects.map((project) => (
<Pressable key={String(project.id)} style={styles.row} onPress={() => router.push(`/project/${project.id}`)}>
<View style={styles.rowHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>{getProjectLine(project)}</Text>
{isProjectCompletedByPhase(project) ? <Text style={styles.archivedBadge}>Abgeschlossen</Text> : null}
</View>
{getActivePhaseLabel(project) ? (
<Text style={styles.phaseText} numberOfLines={1}>Phase: {getActivePhaseLabel(project)}</Text>
) : null}
{project.notes ? <Text style={styles.rowSubtitle} numberOfLines={1}>{String(project.notes)}</Text> : null}
</Pressable>
))}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neues Projekt</Text>
<TextInput
placeholder="Projektname"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={nameInput}
onChangeText={setNameInput}
/>
<TextInput
placeholder="Projekt-Nr. (optional)"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={projectNumberInput}
onChangeText={setProjectNumberInput}
/>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('customer')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedCustomerLabel}</Text>
</Pressable>
<Pressable style={styles.selectButton} onPress={() => setPickerMode('plant')}>
<Text style={styles.selectButtonText} numberOfLines={1}>{selectedPlantLabel}</Text>
</Pressable>
<TextInput
placeholder="Notizen (optional)"
placeholderTextColor="#9ca3af"
style={[styles.searchInput, styles.multilineInput]}
value={notesInput}
onChangeText={setNotesInput}
multiline
/>
{pickerMode === 'customer' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Kunde auswählen</Text>
<TextInput
placeholder="Kunden suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={customerSearch}
onChangeText={setCustomerSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredCustomerOptions.map((customer) => (
<Pressable
key={String(customer.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedCustomerId(Number(customer.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{customer.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>
{customer.customerNumber ? `Nr. ${customer.customerNumber}` : `ID ${customer.id}`}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{pickerMode === 'plant' ? (
<View style={styles.inlinePickerBox}>
<Text style={styles.inlinePickerTitle}>Objekt auswählen</Text>
<TextInput
placeholder="Objekte suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
value={plantSearch}
onChangeText={setPlantSearch}
/>
<ScrollView style={styles.pickerList}>
<Pressable
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(null);
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle}>Keine Auswahl</Text>
</Pressable>
{filteredPlantOptions.map((plant) => (
<Pressable
key={String(plant.id)}
style={styles.pickerRow}
onPress={() => {
setSelectedPlantId(Number(plant.id));
setPickerMode(null);
}}>
<Text style={styles.pickerRowTitle} numberOfLines={1}>{plant.name}</Text>
<Text style={styles.pickerRowMeta} numberOfLines={1}>ID {plant.id}</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateProject}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: '#ffffff' },
searchWrap: {
paddingHorizontal: 16,
paddingTop: 12,
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
gap: 8,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
list: { flex: 1, backgroundColor: '#ffffff' },
listContent: { paddingBottom: 96 },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: '#e5e7eb',
backgroundColor: '#ffffff',
},
rowHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
rowTitle: { flex: 1, color: '#111827', fontSize: 15, fontWeight: '600' },
archivedBadge: {
color: '#3d7a30',
fontSize: 11,
fontWeight: '600',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
overflow: 'hidden',
},
rowSubtitle: { color: '#6b7280', fontSize: 13, marginTop: 2 },
phaseText: { color: '#6b7280', fontSize: 12, marginTop: 2 },
toggleButton: {
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
toggleButtonActive: { borderColor: PRIMARY, backgroundColor: '#eff9ea' },
toggleButtonText: { color: '#374151', fontSize: 12, fontWeight: '600' },
toggleButtonTextActive: { color: '#3d7a30' },
loadingBox: { paddingVertical: 20, alignItems: 'center', gap: 8 },
loadingText: { color: '#6b7280' },
empty: { color: '#6b7280', textAlign: 'center', paddingVertical: 20 },
error: { color: '#dc2626', fontSize: 13, paddingHorizontal: 16, paddingTop: 10 },
fab: {
position: 'absolute',
right: 18,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#111827',
shadowOpacity: 0.25,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 5,
},
fabText: { color: '#ffffff', fontSize: 30, lineHeight: 30, marginTop: -2 },
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(17, 24, 39, 0.45)',
justifyContent: 'center',
padding: 20,
},
modalKeyboardWrap: { width: '100%' },
modalCard: { backgroundColor: '#ffffff', borderRadius: 14, padding: 16, gap: 10 },
modalTitle: { color: '#111827', fontSize: 18, fontWeight: '700' },
multilineInput: { minHeight: 92, textAlignVertical: 'top' },
selectButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: '#ffffff',
},
selectButtonText: { color: '#111827', fontSize: 15 },
inlinePickerBox: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
padding: 10,
gap: 8,
backgroundColor: '#fafafa',
},
inlinePickerTitle: { color: '#111827', fontSize: 14, fontWeight: '700' },
pickerList: { maxHeight: 220 },
pickerRow: {
borderWidth: 1,
borderColor: '#e5e7eb',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 8,
backgroundColor: '#ffffff',
},
pickerRowTitle: { color: '#111827', fontSize: 14, fontWeight: '600' },
pickerRowMeta: { color: '#6b7280', fontSize: 12, marginTop: 2 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 4 },
secondaryButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
},
secondaryButtonText: { color: '#374151', fontWeight: '600' },
primaryButton: {
borderRadius: 10,
minHeight: 40,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: PRIMARY,
},
primaryButtonText: { color: '#ffffff', fontWeight: '700' },
buttonDisabled: { opacity: 0.6 },
});

582
mobile/app/(tabs)/tasks.tsx Normal file
View File

@@ -0,0 +1,582 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { createTask, fetchTasks, fetchTenantProfiles, Task, TaskStatus, updateTask } from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const STATUSES: TaskStatus[] = ['Offen', 'In Bearbeitung', 'Abgeschlossen'];
const PRIMARY = '#69c350';
function normalizeStatus(status: unknown): TaskStatus {
if (status === 'In Bearbeitung' || status === 'Abgeschlossen') return status;
return 'Offen';
}
function getTaskAssigneeId(task: Task): string | null {
return (task.userId || task.user_id || task.profile || null) as string | null;
}
export default function TasksScreen() {
const { token, user, activeTenantId } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [updatingTaskId, setUpdatingTaskId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [profiles, setProfiles] = useState<{ id: string; label: string }[]>([]);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<'Alle' | TaskStatus>('Alle');
const [showCompleted, setShowCompleted] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [showFilterPanel, setShowFilterPanel] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [newTaskName, setNewTaskName] = useState('');
const [newTaskDescription, setNewTaskDescription] = useState('');
const handledActionRef = useRef<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const filteredTasks = useMemo(() => {
const needle = search.trim().toLowerCase();
return tasks
.filter((task) => {
const status = normalizeStatus(task.categorie);
if (!showCompleted && status === 'Abgeschlossen') return false;
const statusMatch = statusFilter === 'Alle' || status === statusFilter;
const textMatch =
!needle ||
[task.name, task.description, task.categorie].some((value) =>
String(value || '').toLowerCase().includes(needle)
);
return statusMatch && textMatch;
})
.sort((a, b) => Number(a.id) - Number(b.id));
}, [search, showCompleted, statusFilter, tasks]);
function getAssigneeLabel(task: Task): string {
const assigneeId = getTaskAssigneeId(task);
if (!assigneeId) return '-';
return profiles.find((profile) => profile.id === assigneeId)?.label || assigneeId;
}
const loadTasks = useCallback(
async (showSpinner = true) => {
if (!token) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const [taskRows, profileRows] = await Promise.all([fetchTasks(token), fetchTenantProfiles(token)]);
setTasks(taskRows || []);
setProfiles(
(profileRows || [])
.map((profile) => {
const id = profile.user_id || (profile.id ? String(profile.id) : null);
const label = profile.full_name || profile.fullName || profile.email || id;
return id ? { id: String(id), label: String(label || id) } : null;
})
.filter((value): value is { id: string; label: string } => Boolean(value))
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Aufgaben konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[token]
);
useEffect(() => {
if (!token || !activeTenantId) return;
void loadTasks(true);
}, [token, activeTenantId, loadTasks]);
useEffect(() => {
if (incomingAction !== 'create') return;
if (handledActionRef.current === incomingAction) return;
handledActionRef.current = incomingAction;
setCreateModalOpen(true);
}, [incomingAction]);
async function onRefresh() {
if (!token) return;
setRefreshing(true);
await loadTasks(false);
}
function closeCreateModal() {
setCreateModalOpen(false);
setCreateError(null);
setNewTaskName('');
setNewTaskDescription('');
handledActionRef.current = null;
}
async function onCreateTask() {
if (!token) return;
const name = newTaskName.trim();
if (!name) {
setCreateError('Bitte einen Aufgabennamen eingeben.');
return;
}
setSaving(true);
setCreateError(null);
try {
await createTask(token, {
name,
description: newTaskDescription.trim() || null,
categorie: 'Offen',
userId: currentUserId,
});
closeCreateModal();
await loadTasks(false);
} catch (err) {
setCreateError(err instanceof Error ? err.message : 'Aufgabe konnte nicht erstellt werden.');
} finally {
setSaving(false);
}
}
async function setTaskStatus(task: Task, status: TaskStatus) {
if (!token || !task?.id) return;
if (normalizeStatus(task.categorie) === status) return;
setUpdatingTaskId(Number(task.id));
setError(null);
try {
await updateTask(token, Number(task.id), { categorie: status });
setTasks((prev) => prev.map((item) => (item.id === task.id ? { ...item, categorie: status } : item)));
} catch (err) {
setError(err instanceof Error ? err.message : 'Status konnte nicht gesetzt werden.');
} finally {
setUpdatingTaskId(null);
}
}
return (
<View style={styles.screen}>
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.topActions}>
<Pressable
style={[styles.topActionButton, showSearchPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowSearchPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showSearchPanel ? styles.topActionTextActive : null]}>Suche</Text>
</Pressable>
<Pressable
style={[styles.topActionButton, showFilterPanel ? styles.topActionButtonActive : null]}
onPress={() => setShowFilterPanel((prev) => !prev)}>
<Text style={[styles.topActionText, showFilterPanel ? styles.topActionTextActive : null]}>Filter</Text>
</Pressable>
</View>
{showSearchPanel ? (
<View style={styles.panel}>
<TextInput
placeholder="Suche"
placeholderTextColor="#9ca3af"
style={styles.input}
value={search}
onChangeText={setSearch}
/>
</View>
) : null}
{showFilterPanel ? (
<View style={styles.panel}>
<View style={styles.filterRow}>
{(['Alle', 'Offen', 'In Bearbeitung'] as const).map((status) => (
<Pressable
key={status}
style={[styles.filterChip, statusFilter === status ? styles.filterChipActive : null]}
onPress={() => setStatusFilter(status)}>
<Text
style={[
styles.filterChipText,
statusFilter === status ? styles.filterChipTextActive : null,
]}>
{status}
</Text>
</Pressable>
))}
<Pressable
style={[styles.filterChip, showCompleted ? styles.filterChipActive : null]}
onPress={() => setShowCompleted((prev) => !prev)}>
<Text style={[styles.filterChipText, showCompleted ? styles.filterChipTextActive : null]}>
Abgeschlossene anzeigen
</Text>
</Pressable>
</View>
</View>
) : null}
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Aufgaben werden geladen...</Text>
</View>
) : null}
{!loading && filteredTasks.length === 0 ? (
<Text style={styles.empty}>Keine Aufgaben gefunden.</Text>
) : null}
{!loading &&
filteredTasks.map((task) => {
const status = normalizeStatus(task.categorie);
const isUpdating = updatingTaskId === Number(task.id);
return (
<View key={String(task.id)} style={styles.taskCard}>
<View style={styles.taskHeader}>
<Text style={styles.taskTitle} numberOfLines={2}>
{task.name}
</Text>
<Text style={styles.statusBadge}>{status}</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={3}>
{task.description}
</Text>
) : null}
<Text style={styles.taskMeta}>Zuweisung: {getAssigneeLabel(task)}</Text>
<View style={styles.actionRow}>
{STATUSES.map((nextStatus) => (
<Pressable
key={nextStatus}
style={[
styles.actionButton,
nextStatus === status ? styles.actionButtonActive : null,
isUpdating ? styles.buttonDisabled : null,
]}
onPress={() => setTaskStatus(task, nextStatus)}
disabled={isUpdating || nextStatus === status}>
<Text style={styles.actionButtonText}>{nextStatus}</Text>
</Pressable>
))}
</View>
</View>
);
})}
</ScrollView>
<Pressable style={styles.fab} onPress={() => setCreateModalOpen(true)}>
<Text style={styles.fabText}>+</Text>
</Pressable>
<Modal visible={createModalOpen} transparent animationType="fade" onRequestClose={closeCreateModal}>
<View style={styles.modalOverlay}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.modalKeyboardWrap}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Neue Aufgabe</Text>
<TextInput
placeholder="Titel"
placeholderTextColor="#9ca3af"
style={styles.input}
value={newTaskName}
onChangeText={setNewTaskName}
/>
<TextInput
placeholder="Beschreibung (optional)"
placeholderTextColor="#9ca3af"
style={[styles.input, styles.inputMultiline]}
multiline
value={newTaskDescription}
onChangeText={setNewTaskDescription}
/>
{createError ? <Text style={styles.error}>{createError}</Text> : null}
<View style={styles.modalActions}>
<Pressable style={styles.secondaryButton} onPress={closeCreateModal} disabled={saving}>
<Text style={styles.secondaryButtonText}>Abbrechen</Text>
</Pressable>
<Pressable
style={[styles.primaryButton, saving ? styles.buttonDisabled : null]}
onPress={onCreateTask}
disabled={saving}>
<Text style={styles.primaryButtonText}>{saving ? 'Speichere...' : 'Anlegen'}</Text>
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#f9fafb',
},
container: {
padding: 16,
gap: 12,
paddingBottom: 96,
},
topActions: {
flexDirection: 'row',
gap: 8,
},
topActionButton: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
backgroundColor: '#ffffff',
},
topActionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
topActionText: {
color: '#374151',
fontWeight: '600',
},
topActionTextActive: {
color: '#3d7a30',
},
panel: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
inputMultiline: {
minHeight: 72,
textAlignVertical: 'top',
},
filterRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
borderWidth: 1,
borderColor: '#d1d5db',
paddingHorizontal: 10,
paddingVertical: 6,
backgroundColor: '#ffffff',
},
filterChipActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
filterChipText: {
color: '#374151',
fontSize: 13,
fontWeight: '500',
},
filterChipTextActive: {
color: '#3d7a30',
},
error: {
color: '#dc2626',
fontSize: 13,
},
loadingBox: {
padding: 16,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
taskCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
gap: 8,
borderWidth: 1,
borderColor: '#e5e7eb',
},
taskHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: 8,
},
taskTitle: {
flex: 1,
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusBadge: {
color: '#3d7a30',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 4,
fontSize: 12,
overflow: 'hidden',
},
taskDescription: {
color: '#374151',
fontSize: 14,
},
taskMeta: {
color: '#6b7280',
fontSize: 12,
},
actionRow: {
flexDirection: 'row',
gap: 8,
flexWrap: 'wrap',
},
actionButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
paddingHorizontal: 10,
paddingVertical: 7,
},
actionButtonActive: {
borderColor: PRIMARY,
backgroundColor: '#eff9ea',
},
actionButtonText: {
color: '#1f2937',
fontSize: 12,
fontWeight: '500',
},
fab: {
position: 'absolute',
right: 18,
bottom: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
elevation: 4,
shadowColor: '#111827',
shadowOpacity: 0.18,
shadowRadius: 10,
shadowOffset: { width: 0, height: 4 },
},
fabText: {
color: '#ffffff',
fontSize: 30,
lineHeight: 30,
fontWeight: '500',
marginTop: -1,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.35)',
justifyContent: 'center',
padding: 16,
},
modalKeyboardWrap: {
width: '100%',
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 14,
gap: 10,
},
modalTitle: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 8,
marginTop: 2,
},
secondaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e5e7eb',
},
secondaryButtonText: {
color: '#111827',
fontWeight: '600',
},
primaryButton: {
minHeight: 42,
borderRadius: 10,
paddingHorizontal: 14,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
primaryButtonText: {
color: '#ffffff',
fontWeight: '600',
},
buttonDisabled: {
opacity: 0.6,
},
});

405
mobile/app/(tabs)/time.tsx Normal file
View File

@@ -0,0 +1,405 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import {
createStaffTimeEvent,
fetchStaffTimeSpans,
StaffTimeSpan,
submitStaffTime,
} from '@/src/lib/api';
import { useAuth } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function formatDateTime(value: string | null): string {
if (!value) return '-';
const d = new Date(value);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}h ${String(m).padStart(2, '0')}m`;
}
function getStateLabel(state: string): string {
if (state === 'approved') return 'Genehmigt';
if (state === 'submitted') return 'Eingereicht';
if (state === 'rejected') return 'Abgelehnt';
if (state === 'factual') return 'Faktisch';
return 'Entwurf';
}
function getTypeLabel(type: string): string {
if (type === 'vacation') return 'Urlaub';
if (type === 'sick') return 'Krankheit';
if (type === 'holiday') return 'Feiertag';
if (type === 'other') return 'Sonstiges';
return 'Arbeitszeit';
}
export default function TimeTrackingScreen() {
const { token, user } = useAuth();
const params = useLocalSearchParams<{ action?: string | string[] }>();
const [entries, setEntries] = useState<StaffTimeSpan[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentUserId = useMemo(() => (user?.id ? String(user.id) : null), [user]);
const handledActionRef = useRef<string | null>(null);
const active = useMemo(() => entries.find((entry) => !entry.stopped_at) || null, [entries]);
const incomingAction = useMemo(() => {
const raw = Array.isArray(params.action) ? params.action[0] : params.action;
return String(raw || '').toLowerCase();
}, [params.action]);
const load = useCallback(
async (showSpinner = true) => {
if (!token || !currentUserId) return;
if (showSpinner) setLoading(true);
setError(null);
try {
const spans = await fetchStaffTimeSpans(token, currentUserId);
setEntries(spans);
} catch (err) {
setError(err instanceof Error ? err.message : 'Zeiten konnten nicht geladen werden.');
} finally {
setLoading(false);
setRefreshing(false);
}
},
[currentUserId, token]
);
useEffect(() => {
if (!currentUserId || !token) return;
void load(true);
}, [currentUserId, load, token]);
async function onRefresh() {
setRefreshing(true);
await load(false);
}
const onStart = useCallback(async () => {
if (!token || !currentUserId) return;
setActionLoading(true);
setError(null);
try {
await createStaffTimeEvent(token, {
eventtype: 'work_start',
eventtime: new Date().toISOString(),
user_id: currentUserId,
description: 'Arbeitszeit gestartet',
});
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Starten fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}, [currentUserId, token, load]);
const onStop = useCallback(async () => {
if (!token || !currentUserId || !active) return;
setActionLoading(true);
setError(null);
try {
await createStaffTimeEvent(token, {
eventtype: 'work_end',
eventtime: new Date().toISOString(),
user_id: currentUserId,
});
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Stoppen fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}, [active, currentUserId, token, load]);
async function onSubmit(entry: StaffTimeSpan) {
if (!token || !entry.eventIds?.length) return;
setActionLoading(true);
setError(null);
try {
await submitStaffTime(token, entry.eventIds);
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}
const onSubmitAll = useCallback(async () => {
if (!token) return;
const submitCandidates = entries.filter(
(entry) => (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at && !!entry.eventIds?.length
);
if (submitCandidates.length === 0) return;
setActionLoading(true);
setError(null);
try {
for (const entry of submitCandidates) {
await submitStaffTime(token, entry.eventIds);
}
await load(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Einreichen fehlgeschlagen.');
} finally {
setActionLoading(false);
}
}, [entries, load, token]);
useEffect(() => {
if (!token || !currentUserId) return;
if (!incomingAction) return;
if (handledActionRef.current === incomingAction) return;
if (incomingAction === 'start' && !active) {
handledActionRef.current = incomingAction;
void onStart().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'stop' && active) {
handledActionRef.current = incomingAction;
void onStop().finally(() => router.replace('/(tabs)/time'));
return;
}
if (incomingAction === 'submit') {
handledActionRef.current = incomingAction;
void onSubmitAll().finally(() => router.replace('/(tabs)/time'));
return;
}
handledActionRef.current = incomingAction;
void router.replace('/(tabs)/time');
}, [active, currentUserId, incomingAction, onStart, onStop, onSubmitAll, token]);
return (
<ScrollView
contentContainerStyle={styles.container}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}>
<View style={styles.statusCard}>
<Text style={styles.statusLabel}>Aktive Zeit</Text>
<Text style={styles.statusValue}>
{active ? `Läuft seit ${formatDateTime(active.started_at)}` : 'Keine aktive Zeit'}
</Text>
<View style={styles.statusActions}>
{active ? (
<Pressable
style={[styles.stopButton, actionLoading ? styles.buttonDisabled : null]}
onPress={onStop}
disabled={actionLoading}>
<Text style={styles.stopButtonText}>Stop</Text>
</Pressable>
) : (
<Pressable
style={[styles.startButton, actionLoading ? styles.buttonDisabled : null]}
onPress={onStart}
disabled={actionLoading}>
<Text style={styles.startButtonText}>Start</Text>
</Pressable>
)}
</View>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
{loading ? (
<View style={styles.loadingBox}>
<ActivityIndicator />
<Text style={styles.loadingText}>Zeiten werden geladen...</Text>
</View>
) : null}
{!loading && entries.length === 0 ? <Text style={styles.empty}>Keine Zeiteinträge vorhanden.</Text> : null}
{!loading &&
entries.map((entry) => {
const canSubmit = (entry.state === 'draft' || entry.state === 'factual') && !!entry.stopped_at;
return (
<View key={`${entry.id}-${entry.started_at}`} style={styles.entryCard}>
<View style={styles.entryHeader}>
<Text style={styles.entryType}>{getTypeLabel(entry.type)}</Text>
<Text style={styles.entryState}>{getStateLabel(entry.state)}</Text>
</View>
<Text style={styles.entryTime}>Start: {formatDateTime(entry.started_at)}</Text>
<Text style={styles.entryTime}>
Ende: {entry.stopped_at ? formatDateTime(entry.stopped_at) : 'läuft...'}
</Text>
<Text style={styles.entryTime}>Dauer: {formatDuration(entry.duration_minutes)}</Text>
{entry.description ? <Text style={styles.entryDescription}>{entry.description}</Text> : null}
{canSubmit ? (
<View style={styles.entryActions}>
<Pressable
style={[styles.actionButton, actionLoading ? styles.buttonDisabled : null]}
onPress={() => onSubmit(entry)}
disabled={actionLoading}>
<Text style={styles.actionButtonText}>Einreichen</Text>
</Pressable>
</View>
) : null}
</View>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 16,
gap: 12,
backgroundColor: '#f9fafb',
},
statusCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
gap: 8,
},
statusLabel: {
color: '#6b7280',
fontSize: 12,
textTransform: 'uppercase',
},
statusValue: {
color: '#111827',
fontSize: 16,
fontWeight: '600',
},
statusActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
},
startButton: {
minHeight: 40,
borderRadius: 10,
backgroundColor: PRIMARY,
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
},
startButtonText: {
color: '#ffffff',
fontWeight: '700',
},
stopButton: {
minHeight: 40,
borderRadius: 10,
backgroundColor: '#dc2626',
paddingHorizontal: 14,
alignItems: 'center',
justifyContent: 'center',
},
stopButtonText: {
color: '#ffffff',
fontWeight: '700',
},
error: {
color: '#dc2626',
fontSize: 13,
},
loadingBox: {
padding: 16,
alignItems: 'center',
gap: 8,
},
loadingText: {
color: '#6b7280',
},
empty: {
color: '#6b7280',
textAlign: 'center',
paddingVertical: 16,
},
entryCard: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
gap: 6,
},
entryHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: 8,
},
entryType: {
color: '#111827',
fontWeight: '600',
},
entryState: {
color: '#3d7a30',
fontWeight: '600',
backgroundColor: '#eff9ea',
borderRadius: 999,
paddingHorizontal: 8,
paddingVertical: 3,
fontSize: 12,
overflow: 'hidden',
},
entryTime: {
color: '#4b5563',
fontSize: 13,
},
entryDescription: {
color: '#374151',
fontSize: 14,
},
entryActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 4,
},
actionButton: {
minHeight: 36,
borderRadius: 8,
backgroundColor: '#0ea5e9',
paddingHorizontal: 10,
alignItems: 'center',
justifyContent: 'center',
},
actionButtonText: {
color: '#ffffff',
fontWeight: '600',
fontSize: 12,
},
buttonDisabled: {
opacity: 0.6,
},
});

119
mobile/app/_layout.tsx Normal file
View File

@@ -0,0 +1,119 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';
import { AuthProvider } from '@/src/providers/auth-provider';
export default function RootLayout() {
return (
<AuthProvider>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="login" options={{ title: 'Login', headerBackVisible: false }} />
<Stack.Screen name="tenant-select" options={{ title: 'Tenant auswählen', headerBackVisible: false }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="project/[id]"
options={{
title: 'Projekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/account"
options={{
title: 'Konto',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/settings"
options={{
title: 'Einstellungen',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/wiki"
options={{
title: 'Wiki',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customers"
options={{
title: 'Kunden',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/customer/[id]"
options={{
title: 'Kunde',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plants"
options={{
title: 'Objekte',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/plant/[id]"
options={{
title: 'Objekt',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/inventory"
options={{
title: 'Kundeninventar',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
<Stack.Screen
name="more/nimbot"
options={{
title: 'Nimbot M2',
headerBackButtonDisplayMode: 'minimal',
headerBackTitle: '',
headerBackTitleVisible: false,
headerTintColor: '#111827',
}}
/>
</Stack>
<StatusBar style="dark" />
</AuthProvider>
);
}

41
mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { useAuth } from '@/src/providers/auth-provider';
export default function IndexScreen() {
const { isBootstrapping, token, requiresTenantSelection } = useAuth();
if (isBootstrapping) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" />
<Text style={styles.copy}>Initialisiere mobile Session...</Text>
</View>
);
}
if (!token) {
return <Redirect href="/login" />;
}
if (requiresTenantSelection) {
return <Redirect href="/tenant-select" />;
}
return <Redirect href="/(tabs)" />;
}
const styles = StyleSheet.create({
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 24,
},
copy: {
fontSize: 14,
color: '#4b5563',
},
});

343
mobile/app/login.tsx Normal file
View File

@@ -0,0 +1,343 @@
import { useCallback, useState } from 'react';
import { Redirect, router, useFocusEffect } from 'expo-router';
import {
ActivityIndicator,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
getApiBaseUrlSync,
hydrateApiBaseUrl,
isServerSetupDone,
markServerSetupDone,
setApiBaseUrl as persistApiBaseUrl,
} from '@/src/lib/server-config';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
function isValidServerUrl(value: string): boolean {
return /^https?:\/\/.+/i.test(value.trim());
}
export default function LoginScreen() {
const { token, requiresTenantSelection, login } = useAuth();
const storageInfo = useTokenStorageInfo();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiBaseUrl, setApiBaseUrl] = useState(getApiBaseUrlSync());
const [serverInput, setServerInput] = useState(getApiBaseUrlSync());
const [showServerModal, setShowServerModal] = useState(false);
const [isServerSetupRequired, setIsServerSetupRequired] = useState(false);
const [isServerSaving, setIsServerSaving] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useFocusEffect(
useCallback(() => {
let active = true;
async function refreshApiBase() {
const current = await hydrateApiBaseUrl();
const setupDone = await isServerSetupDone();
if (!active) return;
setApiBaseUrl(current);
setServerInput(current);
setIsServerSetupRequired(!setupDone);
setShowServerModal(!setupDone);
}
void refreshApiBase();
return () => {
active = false;
};
}, [])
);
if (token) {
return <Redirect href={requiresTenantSelection ? '/tenant-select' : '/(tabs)'} />;
}
async function applyDefaultServer() {
setIsServerSaving(true);
setServerError(null);
try {
await markServerSetupDone();
setIsServerSetupRequired(false);
setShowServerModal(false);
setApiBaseUrl(getApiBaseUrlSync());
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function saveCustomServer() {
setServerError(null);
const value = serverInput.trim();
if (!isValidServerUrl(value)) {
setServerError('Bitte eine gültige URL mit http:// oder https:// eingeben.');
return;
}
setIsServerSaving(true);
try {
const normalized = await persistApiBaseUrl(value);
setApiBaseUrl(normalized);
setServerInput(normalized);
setIsServerSetupRequired(false);
setShowServerModal(false);
} catch (err) {
setServerError(err instanceof Error ? err.message : 'Server-Einstellung konnte nicht gespeichert werden.');
} finally {
setIsServerSaving(false);
}
}
async function onSubmit() {
setIsSubmitting(true);
setError(null);
try {
await login(email.trim(), password);
router.replace('/');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login fehlgeschlagen.');
} finally {
setIsSubmitting(false);
}
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>FEDEO Mobile</Text>
<Text style={styles.subtitle}>Login mit anschliessender Tenant-Auswahl</Text>
<Text style={styles.label}>E-Mail</Text>
<TextInput
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
placeholder="name@firma.de"
placeholderTextColor="#9ca3af"
style={styles.input}
value={email}
onChangeText={setEmail}
/>
<Text style={styles.label}>Passwort</Text>
<TextInput
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#9ca3af"
style={styles.input}
value={password}
onChangeText={setPassword}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, isSubmitting ? styles.buttonDisabled : null]}
onPress={onSubmit}
disabled={isSubmitting || isServerSetupRequired || !email || !password}>
{isSubmitting ? <ActivityIndicator color="#ffffff" /> : <Text style={styles.buttonText}>Anmelden</Text>}
</Pressable>
<Pressable style={styles.serverLink} onPress={() => setShowServerModal(true)}>
<Text style={styles.serverLinkText}>Eigenen Server festlegen</Text>
</Pressable>
<View style={styles.metaBox}>
<Text style={styles.metaText}>API: {apiBaseUrl}</Text>
<Text style={styles.metaText}>Token Storage: {storageInfo.mode}</Text>
</View>
</View>
<Modal visible={showServerModal} transparent animationType="fade" onRequestClose={() => {}}>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>Server-Instanz festlegen</Text>
<Text style={styles.modalText}>
Vor dem ersten Login bitte Server wählen. Standard verwenden oder eigene Instanz hinterlegen.
</Text>
<TextInput
value={serverInput}
onChangeText={setServerInput}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="https://dein-server.tld"
placeholderTextColor="#9ca3af"
style={styles.input}
/>
{serverError ? <Text style={styles.error}>{serverError}</Text> : null}
<Pressable
style={[styles.modalPrimaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={saveCustomServer}
disabled={isServerSaving}>
<Text style={styles.modalPrimaryText}>{isServerSaving ? 'Speichern...' : 'Eigene Instanz speichern'}</Text>
</Pressable>
<Pressable
style={[styles.modalSecondaryButton, isServerSaving ? styles.buttonDisabled : null]}
onPress={applyDefaultServer}
disabled={isServerSaving}>
<Text style={styles.modalSecondaryText}>Standardserver verwenden</Text>
</Pressable>
</View>
</View>
</Modal>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
padding: 20,
backgroundColor: '#f3f4f6',
},
card: {
backgroundColor: '#ffffff',
borderRadius: 16,
padding: 20,
gap: 10,
shadowColor: '#111827',
shadowOpacity: 0.08,
shadowRadius: 16,
shadowOffset: { width: 0, height: 8 },
elevation: 3,
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
marginBottom: 8,
},
label: {
fontSize: 14,
color: '#374151',
fontWeight: '500',
},
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
color: '#111827',
},
button: {
marginTop: 6,
backgroundColor: PRIMARY,
borderRadius: 10,
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
},
buttonDisabled: {
backgroundColor: '#86efac',
},
buttonText: {
color: '#ffffff',
fontSize: 16,
fontWeight: '600',
},
error: {
color: '#dc2626',
fontSize: 13,
},
metaBox: {
marginTop: 8,
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: '#e5e7eb',
gap: 4,
},
serverLink: {
marginTop: 2,
alignSelf: 'flex-start',
paddingVertical: 4,
},
serverLinkText: {
color: PRIMARY,
fontSize: 13,
fontWeight: '600',
},
metaText: {
fontSize: 12,
color: '#6b7280',
},
modalBackdrop: {
flex: 1,
backgroundColor: 'rgba(17, 24, 39, 0.45)',
justifyContent: 'center',
padding: 20,
},
modalCard: {
backgroundColor: '#ffffff',
borderRadius: 14,
padding: 16,
gap: 10,
},
modalTitle: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
modalText: {
color: '#6b7280',
fontSize: 13,
lineHeight: 18,
},
modalPrimaryButton: {
minHeight: 42,
borderRadius: 10,
backgroundColor: PRIMARY,
alignItems: 'center',
justifyContent: 'center',
},
modalPrimaryText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 14,
},
modalSecondaryButton: {
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ffffff',
},
modalSecondaryText: {
color: '#374151',
fontWeight: '600',
fontSize: 14,
},
});

29
mobile/app/modal.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

View File

@@ -0,0 +1,199 @@
import { useMemo, useState } from 'react';
import { Redirect, router } from 'expo-router';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { useAuth, useTokenStorageInfo } from '@/src/providers/auth-provider';
const PRIMARY = '#69c350';
export default function TenantSelectScreen() {
const { token, tenants, activeTenantId, requiresTenantSelection, switchTenant, user, logout } = useAuth();
const storageInfo = useTokenStorageInfo();
const [switchingTenantId, setSwitchingTenantId] = useState<number | null>(null);
const [search, setSearch] = useState('');
const [error, setError] = useState<string | null>(null);
const filteredTenants = useMemo(() => {
const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return tenants;
return tenants.filter((tenant) => {
const haystack = `${String(tenant.name || '').toLowerCase()} ${String(tenant.id || '').toLowerCase()} ${
String(tenant.short || '').toLowerCase()
}`;
return terms.every((term) => haystack.includes(term));
});
}, [search, tenants]);
if (!token) {
return <Redirect href="/login" />;
}
if (!requiresTenantSelection && activeTenantId) {
return <Redirect href="/(tabs)" />;
}
async function onSelectTenant(tenantId: number) {
setSwitchingTenantId(tenantId);
setError(null);
try {
await switchTenant(tenantId);
router.replace('/(tabs)');
} catch (err) {
setError(err instanceof Error ? err.message : 'Tenant konnte nicht gewechselt werden.');
} finally {
setSwitchingTenantId(null);
}
}
async function onLogout() {
setSwitchingTenantId(null);
await logout();
router.replace('/login');
}
return (
<ScrollView contentContainerStyle={styles.container}>
<View style={styles.headerCard}>
<Text style={styles.title}>Tenant auswählen</Text>
<Text style={styles.subtitle}>Wähle den Mandanten für diese Session.</Text>
<Text style={styles.meta}>User: {String(user?.email || user?.id || 'unbekannt')}</Text>
<Text style={styles.meta}>Storage: {storageInfo.mode}</Text>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Tenant suchen"
placeholderTextColor="#9ca3af"
style={styles.searchInput}
/>
{filteredTenants.length === 0 ? <Text style={styles.empty}>Keine passenden Tenants gefunden.</Text> : null}
{filteredTenants.map((tenant) => {
const tenantId = Number(tenant.id);
const isBusy = switchingTenantId === tenantId;
return (
<Pressable
key={String(tenant.id)}
style={[styles.tenantButton, isBusy ? styles.tenantButtonDisabled : null]}
onPress={() => onSelectTenant(tenantId)}
disabled={switchingTenantId !== null}>
<View style={styles.tenantInfo}>
<Text style={styles.tenantName} numberOfLines={1}>
{tenant.name}
</Text>
<Text style={styles.tenantMeta}>ID: {tenantId}</Text>
</View>
{isBusy ? <ActivityIndicator color={PRIMARY} /> : <Text style={styles.tenantAction}>Auswählen</Text>}
</Pressable>
);
})}
<Pressable style={styles.logoutButton} onPress={onLogout} disabled={switchingTenantId !== null}>
<Text style={styles.logoutText}>Anderen Nutzer anmelden</Text>
</Pressable>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flexGrow: 1,
padding: 16,
gap: 12,
backgroundColor: '#f9fafb',
},
headerCard: {
borderRadius: 12,
borderWidth: 1,
borderColor: '#e5e7eb',
backgroundColor: '#ffffff',
padding: 14,
gap: 4,
},
title: {
fontSize: 22,
fontWeight: '700',
color: '#111827',
},
subtitle: {
color: '#6b7280',
marginBottom: 2,
},
meta: {
color: '#6b7280',
fontSize: 12,
},
error: {
color: '#dc2626',
fontSize: 13,
},
searchInput: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: '#111827',
backgroundColor: '#ffffff',
},
tenantButton: {
borderRadius: 12,
padding: 14,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
tenantButtonDisabled: {
opacity: 0.6,
},
tenantInfo: {
flex: 1,
minWidth: 0,
paddingRight: 10,
},
tenantName: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
},
tenantMeta: {
color: '#6b7280',
marginTop: 4,
},
tenantAction: {
color: PRIMARY,
fontWeight: '600',
},
empty: {
color: '#6b7280',
fontSize: 13,
textAlign: 'center',
paddingVertical: 8,
},
logoutButton: {
marginTop: 4,
minHeight: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: '#d1d5db',
backgroundColor: '#ffffff',
alignItems: 'center',
justifyContent: 'center',
},
logoutText: {
color: '#374151',
fontWeight: '600',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,25 @@
import { Href, Link } from 'expo-router';
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
import { type ComponentProps } from 'react';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
});
}
}}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

View File

@@ -0,0 +1,19 @@
import Animated from 'react-native-reanimated';
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
'50%': { transform: [{ rotate: '25deg' }] },
},
animationIterationCount: 4,
animationDuration: '300ms',
}}>
👋
</Animated.Text>
);
}

View File

@@ -0,0 +1,79 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/themed-view';
import { useColorScheme } from '@/hooks/use-color-scheme';
import { useThemeColor } from '@/hooks/use-theme-color';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, 'background');
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollOffset(scrollRef);
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View File

@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View File

@@ -0,0 +1,14 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/use-theme-color';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View File

@@ -0,0 +1,45 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
import { IconSymbol } from '@/components/ui/icon-symbol';
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View File

@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight, SymbolViewProps } from 'expo-symbols';
import { ComponentProps } from 'react';
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
type IconSymbolName = keyof typeof MAPPING;
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as IconMapping;
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<TextStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

53
mobile/constants/theme.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#69c350';
const tintColorDark = '#69c350';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});

10
mobile/eslint.config.js Normal file
View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
module.exports = defineConfig([
expoConfig,
{
ignores: ['dist/*'],
},
]);

View File

@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View File

@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

12961
mobile/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
mobile/package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "mobile",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start --dev-client --host lan",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios",
"ios:device": "LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 expo run:ios --device",
"web": "expo start --web",
"lint": "expo lint",
"build:ios:dev": "eas build --profile development --platform ios",
"build:ios:preview": "eas build --profile preview --platform ios"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-camera": "~17.0.10",
"expo-constants": "~18.0.13",
"expo-document-picker": "^14.0.8",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-image-picker": "~17.0.8",
"expo-linking": "~8.0.11",
"expo-router": "~6.0.23",
"expo-secure-store": "^15.0.8",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-ble-plx": "^3.5.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webview": "^13.16.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
},
"private": true
}

112
mobile/scripts/reset-project.js Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);

2
mobile/src/config/env.ts Normal file
View File

@@ -0,0 +1,2 @@
export const DEFAULT_API_BASE_URL =
process.env.EXPO_PUBLIC_API_BASE?.replace(/\/$/, '') || 'http://localhost:3100';

937
mobile/src/lib/api.ts Normal file
View File

@@ -0,0 +1,937 @@
import { getApiBaseUrlSync } from '@/src/lib/server-config';
export type Tenant = {
id: number;
name: string;
short?: string | null;
[key: string]: unknown;
};
export type TaskStatus = 'Offen' | 'In Bearbeitung' | 'Abgeschlossen';
export type Task = {
id: number;
name: string;
description?: string | null;
categorie?: string | null;
userId?: string | null;
user_id?: string | null;
profile?: string | null;
project?: number | { id?: number; name?: string } | null;
customer?: number | { id?: number; name?: string } | null;
plant?: number | { id?: number; name?: string } | null;
archived?: boolean;
[key: string]: unknown;
};
export type TenantProfile = {
id?: number | string;
user_id?: string;
full_name?: string;
fullName?: string;
email?: string;
[key: string]: unknown;
};
export type StaffTimeSpan = {
id: string | null;
eventIds: string[];
state: string;
started_at: string;
stopped_at: string | null;
duration_minutes: number;
user_id: string | null;
type: string;
description: string;
};
export type Project = {
id: number;
name: string;
notes?: string | null;
projectNumber?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type ProjectFile = {
id: string;
name?: string | null;
path?: string | null;
project?: number | { id?: number; name?: string };
customer?: number | { id?: number; name?: string };
plant?: number | { id?: number; name?: string };
createddocument?: number | { id?: number; documentNumber?: string };
mimeType?: string | null;
url?: string;
archived?: boolean;
[key: string]: unknown;
};
export type Customer = {
id: number;
name: string;
customerNumber?: string | null;
notes?: string | null;
archived?: boolean;
[key: string]: unknown;
};
export type Plant = {
id: number;
name: string;
description?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type CustomerInventoryItem = {
id: number;
name: string;
customer?: number | { id?: number; name?: string } | null;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
manufacturer?: string | null;
manufacturerNumber?: string | null;
quantity?: number | null;
archived?: boolean;
[key: string]: unknown;
};
export type CreatedDocument = {
id: number;
documentNumber?: string | null;
title?: string | null;
type?: string | null;
state?: string | null;
documentDate?: string | null;
customer?: number | { id?: number; name?: string };
archived?: boolean;
[key: string]: unknown;
};
export type MeResponse = {
user: {
id: string;
email: string;
must_change_password?: boolean;
[key: string]: unknown;
};
tenants: Tenant[];
activeTenant: number | string | null;
profile: Record<string, unknown> | null;
permissions: string[];
};
export type EncodedLabelRow = {
dataType: 'pixels' | 'void' | 'check';
rowNumber: number;
repeat: number;
rowData?: Uint8Array | number[] | Record<string, number>;
blackPixelsCount: number;
};
export type EncodedLabelImage = {
cols: number;
rows: number;
rowsData: EncodedLabelRow[];
};
export type PrintLabelResponse = {
encoded: EncodedLabelImage;
base64?: string;
};
export type WikiTreeItem = {
id: string;
parentId?: string | null;
title: string;
isFolder?: boolean;
isVirtual?: boolean;
sortOrder?: number;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
export type WikiPage = {
id: string;
title: string;
content?: unknown;
parentId?: string | null;
isFolder?: boolean;
entityType?: string | null;
entityId?: number | null;
entityUuid?: string | null;
updatedAt?: string;
[key: string]: unknown;
};
type RequestOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
token?: string | null;
body?: unknown;
};
function buildUrl(path: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${getApiBaseUrlSync()}${normalizedPath}`;
}
async function parseJson(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
}
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await fetch(buildUrl(path), {
method: options.method || 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const payload = await parseJson(response);
if (!response.ok) {
const message =
(payload as { message?: string; error?: string } | null)?.message ||
(payload as { message?: string; error?: string } | null)?.error ||
`Request failed (${response.status}) for ${path}`;
throw new Error(message);
}
return payload as T;
}
export async function checkBackendHealth(): Promise<{ status: string; [key: string]: unknown }> {
return apiRequest<{ status: string; [key: string]: unknown }>('/health');
}
export async function renderPrintLabel(
token: string,
context: Record<string, unknown>,
width = 584,
height = 354
): Promise<PrintLabelResponse> {
return apiRequest<PrintLabelResponse>('/api/print/label', {
method: 'POST',
token,
body: {
context,
width,
height,
},
});
}
export async function loginWithEmailPassword(email: string, password: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/auth/login', {
method: 'POST',
body: { email, password },
});
if (!payload?.token) {
throw new Error('Login did not return a token.');
}
return payload.token;
}
export async function fetchMe(token: string): Promise<MeResponse> {
return apiRequest<MeResponse>('/api/me', {
token,
});
}
export async function switchTenantRequest(tenantId: number, token: string): Promise<string> {
const payload = await apiRequest<{ token?: string }>('/api/tenant/switch', {
method: 'POST',
token,
body: { tenant_id: String(tenantId) },
});
if (!payload?.token) {
throw new Error('Tenant switch did not return a token.');
}
return payload.token;
}
export async function fetchTasks(token: string): Promise<Task[]> {
const tasks = await apiRequest<Task[]>('/api/resource/tasks', { token });
return (tasks || []).filter((task) => !task.archived);
}
export async function createTask(
token: string,
payload: {
name: string;
description?: string | null;
categorie?: TaskStatus;
userId?: string | null;
project?: number | null;
customer?: number | null;
plant?: number | null;
}
): Promise<Task> {
return apiRequest<Task>('/api/resource/tasks', {
method: 'POST',
token,
body: payload,
});
}
export async function updateTask(token: string, taskId: number, payload: Partial<Task>): Promise<Task> {
return apiRequest<Task>(`/api/resource/tasks/${taskId}`, {
method: 'PUT',
token,
body: payload,
});
}
export async function fetchTenantProfiles(token: string): Promise<TenantProfile[]> {
const response = await apiRequest<{ data?: TenantProfile[] }>('/api/tenant/profiles', { token });
return response?.data || [];
}
export async function fetchStaffTimeSpans(
token: string,
targetUserId?: string
): Promise<StaffTimeSpan[]> {
const query = targetUserId ? `?targetUserId=${encodeURIComponent(targetUserId)}` : '';
const spans = await apiRequest<any[]>(`/api/staff/time/spans${query}`, { token });
return (spans || [])
.map((span) => {
const started = span.startedAt ? new Date(span.startedAt) : null;
const ended = span.endedAt ? new Date(span.endedAt) : new Date();
const durationMinutes =
started && ended ? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 60000)) : 0;
return {
id: span.sourceEventIds?.[0] ?? null,
eventIds: span.sourceEventIds || [],
state: span.status || 'draft',
started_at: span.startedAt,
stopped_at: span.endedAt || null,
duration_minutes: durationMinutes,
user_id: targetUserId || null,
type: span.type || 'work',
description: span.payload?.description || '',
} as StaffTimeSpan;
})
.sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime());
}
export async function createStaffTimeEvent(
token: string,
payload: {
eventtype: string;
eventtime: string;
user_id: string;
description?: string;
}
): Promise<void> {
await apiRequest('/api/staff/time/event', {
method: 'POST',
token,
body: {
eventtype: payload.eventtype,
eventtime: payload.eventtime,
user_id: payload.user_id,
payload: payload.description ? { description: payload.description } : undefined,
},
});
}
export async function submitStaffTime(token: string, eventIds: string[]): Promise<void> {
await apiRequest('/api/staff/time/submit', {
method: 'POST',
token,
body: { eventIds },
});
}
export async function approveStaffTime(
token: string,
eventIds: string[],
employeeUserId: string
): Promise<void> {
await apiRequest('/api/staff/time/approve', {
method: 'POST',
token,
body: { eventIds, employeeUserId },
});
}
export async function rejectStaffTime(
token: string,
eventIds: string[],
employeeUserId: string,
reason: string
): Promise<void> {
await apiRequest('/api/staff/time/reject', {
method: 'POST',
token,
body: { eventIds, employeeUserId, reason },
});
}
export async function fetchProjects(token: string, includeArchived = false): Promise<Project[]> {
const projects = await apiRequest<Project[]>('/api/resource/projects', { token });
if (includeArchived) return projects || [];
return (projects || []).filter((project) => !project.archived);
}
export async function createProject(
token: string,
payload: {
name: string;
projectNumber?: string | null;
customer?: number | null;
plant?: number | null;
notes?: string | null;
}
): Promise<Project> {
return apiRequest<Project>('/api/resource/projects', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomers(token: string, includeArchived = false): Promise<Customer[]> {
const customers = await apiRequest<Customer[]>('/api/resource/customers', { token });
if (includeArchived) return customers || [];
return (customers || []).filter((customer) => !customer.archived);
}
export async function createCustomer(
token: string,
payload: {
name: string;
customerNumber?: string | null;
notes?: string | null;
}
): Promise<Customer> {
return apiRequest<Customer>('/api/resource/customers', {
method: 'POST',
token,
body: payload,
});
}
export async function fetchCustomerById(token: string, customerId: number): Promise<Customer> {
return apiRequest<Customer>(`/api/resource/customers/${customerId}`, { token });
}
function resolveCustomerIdFromCustomerInventoryItem(item: CustomerInventoryItem): number | null {
const rawCustomer = item.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchCustomerInventoryItems(
token: string,
customerId: number,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
return (rows || []).filter((item) => {
if (!includeArchived && item.archived) return false;
return resolveCustomerIdFromCustomerInventoryItem(item) === Number(customerId);
});
}
export async function fetchAllCustomerInventoryItems(
token: string,
includeArchived = false
): Promise<CustomerInventoryItem[]> {
const rows = await apiRequest<CustomerInventoryItem[]>('/api/resource/customerinventoryitems', { token });
if (includeArchived) return rows || [];
return (rows || []).filter((item) => !item.archived);
}
export async function createCustomerInventoryItem(
token: string,
payload: {
customer: number;
name: string;
customerInventoryId?: string | null;
serialNumber?: string | null;
description?: string | null;
quantity?: number | null;
}
): Promise<CustomerInventoryItem> {
const autoInventoryId = `MOB-${Date.now()}`;
return apiRequest<CustomerInventoryItem>('/api/resource/customerinventoryitems', {
method: 'POST',
token,
body: {
customer: payload.customer,
name: payload.name,
customerInventoryId: payload.customerInventoryId?.trim() || autoInventoryId,
serialNumber: payload.serialNumber?.trim() || null,
description: payload.description?.trim() || null,
quantity: Number.isFinite(Number(payload.quantity)) ? Number(payload.quantity) : 1,
},
});
}
export async function fetchPlants(token: string, includeArchived = false): Promise<Plant[]> {
const plants = await apiRequest<Array<Plant & { description?: unknown }>>('/api/resource/plants', { token });
const normalized = (plants || []).map((plant) => {
const legacyDescription = typeof plant.description === 'string'
? plant.description
: (plant.description && typeof plant.description === 'object' && 'text' in (plant.description as Record<string, unknown>)
? String((plant.description as Record<string, unknown>).text || '')
: null);
return {
...plant,
description: legacyDescription || null,
} as Plant;
});
if (includeArchived) return normalized;
return normalized.filter((plant) => !plant.archived);
}
export async function createPlant(
token: string,
payload: {
name: string;
description?: string | null;
customer?: number | null;
}
): Promise<Plant> {
return apiRequest<Plant>('/api/resource/plants', {
method: 'POST',
token,
body: {
name: payload.name,
customer: payload.customer ?? null,
description: {
text: payload.description?.trim() || '',
html: '',
json: [],
},
},
});
}
export async function fetchPlantById(token: string, plantId: number): Promise<Plant> {
return apiRequest<Plant>(`/api/resource/plants/${plantId}`, { token });
}
function toQueryString(params: Record<string, unknown>): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
searchParams.append(key, String(value));
});
const query = searchParams.toString();
return query ? `?${query}` : '';
}
export async function fetchWikiTree(
token: string,
filters: {
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
} = {}
): Promise<WikiTreeItem[]> {
const query = toQueryString({
entityType: filters.entityType,
entityId: filters.entityId,
entityUuid: filters.entityUuid,
});
return apiRequest<WikiTreeItem[]>(`/api/wiki/tree${query}`, { token });
}
export async function fetchWikiPageById(token: string, pageId: string): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, { token });
}
export async function createWikiPage(
token: string,
payload: {
title: string;
parentId?: string | null;
isFolder?: boolean;
entityType?: string;
entityId?: number | null;
entityUuid?: string | null;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>('/api/wiki', {
method: 'POST',
token,
body: payload,
});
}
export async function updateWikiPage(
token: string,
pageId: string,
payload: {
title?: string;
content?: unknown;
parentId?: string | null;
sortOrder?: number;
isFolder?: boolean;
}
): Promise<WikiPage> {
return apiRequest<WikiPage>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'PATCH',
token,
body: payload,
});
}
export async function deleteWikiPage(token: string, pageId: string): Promise<{ success: boolean; deletedId?: string }> {
return apiRequest<{ success: boolean; deletedId?: string }>(`/api/wiki/${encodeURIComponent(pageId)}`, {
method: 'DELETE',
token,
});
}
export async function fetchProjectById(token: string, projectId: number): Promise<Project> {
return apiRequest<Project>(`/api/resource/projects/${projectId}`, { token });
}
function resolveProjectIdFromTask(task: Task): number | null {
const rawProject = task.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
export async function fetchProjectTasks(token: string, projectId: number): Promise<Task[]> {
const tasks = await fetchTasks(token);
return (tasks || []).filter((task) => resolveProjectIdFromTask(task) === Number(projectId));
}
export async function createProjectTask(
token: string,
payload: {
projectId: number;
name: string;
description?: string | null;
userId?: string | null;
categorie?: TaskStatus;
}
): Promise<Task> {
return createTask(token, {
name: payload.name,
description: payload.description || null,
userId: payload.userId || null,
categorie: payload.categorie || 'Offen',
project: payload.projectId,
});
}
function resolveProjectIdFromFile(file: ProjectFile): number | null {
const rawProject = file.project;
if (!rawProject) return null;
if (typeof rawProject === 'object') {
return rawProject.id ? Number(rawProject.id) : null;
}
return Number(rawProject);
}
function resolveCustomerIdFromFile(file: ProjectFile): number | null {
const rawCustomer = file.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
function resolvePlantIdFromFile(file: ProjectFile): number | null {
const rawPlant = file.plant;
if (!rawPlant) return null;
if (typeof rawPlant === 'object') {
return rawPlant.id ? Number(rawPlant.id) : null;
}
return Number(rawPlant);
}
function resolveCreatedDocumentIdFromFile(file: ProjectFile): number | null {
const rawCreatedDocument = file.createddocument;
if (!rawCreatedDocument) return null;
if (typeof rawCreatedDocument === 'object') {
return rawCreatedDocument.id ? Number(rawCreatedDocument.id) : null;
}
return Number(rawCreatedDocument);
}
function resolveCustomerIdFromCreatedDocument(doc: CreatedDocument): number | null {
const rawCustomer = doc.customer;
if (!rawCustomer) return null;
if (typeof rawCustomer === 'object') {
return rawCustomer.id ? Number(rawCustomer.id) : null;
}
return Number(rawCustomer);
}
export async function fetchProjectFiles(token: string, projectId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const projectFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveProjectIdFromFile(file) === Number(projectId);
});
if (projectFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: projectFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerFiles(token: string, customerId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const customerFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCustomerIdFromFile(file) === Number(customerId);
});
if (customerFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: customerFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchPlantFiles(token: string, plantId: number): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const plantFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolvePlantIdFromFile(file) === Number(plantId);
});
if (plantFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: plantFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function fetchCustomerCreatedDocuments(
token: string,
customerId: number
): Promise<CreatedDocument[]> {
const docs = await apiRequest<CreatedDocument[]>('/api/resource/createddocuments', { token });
return (docs || [])
.filter((doc) => !doc.archived && resolveCustomerIdFromCreatedDocument(doc) === Number(customerId))
.sort((a, b) => {
const dateA = new Date(String(a.documentDate || '')).getTime();
const dateB = new Date(String(b.documentDate || '')).getTime();
return dateB - dateA;
});
}
export async function fetchCreatedDocumentFiles(
token: string,
createdDocumentId: number
): Promise<ProjectFile[]> {
const files = await apiRequest<ProjectFile[]>('/api/resource/files', { token });
const createdDocumentFiles = (files || []).filter((file) => {
if (file.archived) return false;
return resolveCreatedDocumentIdFromFile(file) === Number(createdDocumentId);
});
if (createdDocumentFiles.length === 0) return [];
const presigned = await apiRequest<{ files?: ProjectFile[] }>('/api/files/presigned', {
method: 'POST',
token,
body: { ids: createdDocumentFiles.map((file) => file.id) },
});
return presigned.files || [];
}
export async function uploadProjectFile(
token: string,
payload: {
projectId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
project: payload.projectId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadCustomerFile(
token: string,
payload: {
customerId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
customer: payload.customerId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}
export async function uploadPlantFile(
token: string,
payload: {
plantId: number;
uri: string;
filename: string;
mimeType?: string;
}
): Promise<ProjectFile> {
const formData = new FormData();
formData.append(
'file',
{
uri: payload.uri,
name: payload.filename,
type: payload.mimeType || 'application/octet-stream',
} as any
);
formData.append(
'meta',
JSON.stringify({
plant: payload.plantId,
name: payload.filename,
mimeType: payload.mimeType || 'application/octet-stream',
})
);
const response = await fetch(buildUrl('/api/files/upload'), {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
Accept: 'application/json',
},
body: formData,
});
const parsed = await parseJson(response);
if (!response.ok) {
const message =
(parsed as { message?: string; error?: string } | null)?.message ||
(parsed as { message?: string; error?: string } | null)?.error ||
'Upload fehlgeschlagen.';
throw new Error(message);
}
return parsed as ProjectFile;
}

View File

@@ -0,0 +1,44 @@
import * as SecureStore from 'expo-secure-store';
const TOKEN_KEY = 'fedeo.mobile.auth.token';
let memoryToken: string | null = null;
async function hasSecureStore(): Promise<boolean> {
try {
return await SecureStore.isAvailableAsync();
} catch {
return false;
}
}
export async function getStoredToken(): Promise<string | null> {
if (await hasSecureStore()) {
const token = await SecureStore.getItemAsync(TOKEN_KEY);
memoryToken = token;
return token;
}
return memoryToken;
}
export async function setStoredToken(token: string): Promise<void> {
memoryToken = token;
if (await hasSecureStore()) {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
}
export async function clearStoredToken(): Promise<void> {
memoryToken = null;
if (await hasSecureStore()) {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}
}
export const tokenStorageInfo = {
mode: 'secure-store',
key: TOKEN_KEY,
} as const;

View File

@@ -0,0 +1,188 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { fetchMe, loginWithEmailPassword, MeResponse, switchTenantRequest, Tenant } from '@/src/lib/api';
import { hydrateApiBaseUrl } from '@/src/lib/server-config';
import { clearStoredToken, getStoredToken, setStoredToken, tokenStorageInfo } from '@/src/lib/token-storage';
export type AuthUser = MeResponse['user'];
type AuthContextValue = {
isBootstrapping: boolean;
token: string | null;
user: AuthUser | null;
tenants: Tenant[];
activeTenantId: number | null;
activeTenant: Tenant | null;
profile: Record<string, unknown> | null;
permissions: string[];
requiresTenantSelection: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
switchTenant: (tenantId: number) => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function normalizeTenantId(value: number | string | null | undefined): number | null {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? null : parsed;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isBootstrapping, setIsBootstrapping] = useState(true);
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [activeTenantId, setActiveTenantId] = useState<number | null>(null);
const [profile, setProfile] = useState<Record<string, unknown> | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const resetSession = useCallback(() => {
setUser(null);
setTenants([]);
setActiveTenantId(null);
setProfile(null);
setPermissions([]);
}, []);
const hydrateSession = useCallback(
async (tokenToUse: string) => {
const me = await fetchMe(tokenToUse);
setUser(me.user);
setTenants(me.tenants || []);
setActiveTenantId(normalizeTenantId(me.activeTenant));
setProfile(me.profile || null);
setPermissions(me.permissions || []);
},
[]
);
const logout = useCallback(async () => {
await clearStoredToken();
setToken(null);
resetSession();
}, [resetSession]);
const refreshUser = useCallback(async () => {
if (!token) {
resetSession();
return;
}
try {
await hydrateSession(token);
} catch {
await logout();
}
}, [hydrateSession, logout, resetSession, token]);
useEffect(() => {
async function bootstrap() {
await hydrateApiBaseUrl();
const storedToken = await getStoredToken();
if (!storedToken) {
setIsBootstrapping(false);
return;
}
try {
await hydrateSession(storedToken);
setToken(storedToken);
} catch {
await clearStoredToken();
setToken(null);
resetSession();
} finally {
setIsBootstrapping(false);
}
}
void bootstrap();
}, [hydrateSession, resetSession]);
const login = useCallback(
async (email: string, password: string) => {
const nextToken = await loginWithEmailPassword(email, password);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
},
[hydrateSession]
);
const switchTenant = useCallback(
async (tenantId: number) => {
if (!token) {
throw new Error('No active session found.');
}
const nextToken = await switchTenantRequest(tenantId, token);
await setStoredToken(nextToken);
await hydrateSession(nextToken);
setToken(nextToken);
},
[token, hydrateSession]
);
const activeTenant = useMemo(
() => tenants.find((tenant) => Number(tenant.id) === activeTenantId) || null,
[activeTenantId, tenants]
);
const requiresTenantSelection = Boolean(token) && tenants.length > 0 && !activeTenantId;
const value = useMemo(
() => ({
isBootstrapping,
token,
user,
tenants,
activeTenantId,
activeTenant,
profile,
permissions,
requiresTenantSelection,
login,
logout,
refreshUser,
switchTenant,
}),
[
activeTenant,
activeTenantId,
isBootstrapping,
login,
logout,
permissions,
profile,
refreshUser,
requiresTenantSelection,
switchTenant,
tenants,
token,
user,
]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}
export function useTokenStorageInfo() {
return tokenStorageInfo;
}

17
mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts"
]
}